From 68406ee89129bd399775ddfcb3ec0a3ae459bf05 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 01:46:59 -0700 Subject: [PATCH 001/103] Establish Zagkit product contract --- .gitattributes | 4 + .github/workflows/contracts.yml | 20 ++ .gitignore | 19 ++ CHANGELOG.md | 18 ++ CONTRIBUTING.md | 54 +++++ DEPENDENCIES.md | 59 ++++++ GOVERNANCE.md | 49 +++++ README.md | 68 +++++- ROADMAP.md | 91 ++++++++ SECURITY.md | 15 ++ SUPPORT.md | 49 +++++ VERSIONING.md | 46 +++++ benchmarks/README.md | 19 ++ contracts/benchmark-scenes.json | 116 +++++++++++ contracts/components.json | 68 ++++++ contracts/platforms.json | 119 +++++++++++ contracts/toolchain.json | 18 ++ contracts/upstream-zag.json | 130 ++++++++++++ docs/architecture/README.md | 31 +++ docs/design/visual-direction.md | 41 ++++ docs/milestones/0000-product-contract.md | 41 ++++ docs/quality/release-gates.md | 82 ++++++++ docs/rfcs/0000-rfc-process.md | 36 ++++ .../0001-product-and-platform-contract.md | 63 ++++++ .../0002-declarative-core-and-rendering.md | 65 ++++++ docs/rfcs/0003-text-semantics-and-input.md | 54 +++++ .../0004-platform-seams-and-backend-truth.md | 54 +++++ .../rfcs/0005-quality-and-release-contract.md | 37 ++++ docs/rfcs/README.md | 13 ++ tools/check-contracts.sh | 194 ++++++++++++++++++ zag.mod | 3 + 31 files changed, 1675 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/contracts.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 DEPENDENCIES.md create mode 100644 GOVERNANCE.md create mode 100644 ROADMAP.md create mode 100644 SECURITY.md create mode 100644 SUPPORT.md create mode 100644 VERSIONING.md create mode 100644 benchmarks/README.md create mode 100644 contracts/benchmark-scenes.json create mode 100644 contracts/components.json create mode 100644 contracts/platforms.json create mode 100644 contracts/toolchain.json create mode 100644 contracts/upstream-zag.json create mode 100644 docs/architecture/README.md create mode 100644 docs/design/visual-direction.md create mode 100644 docs/milestones/0000-product-contract.md create mode 100644 docs/quality/release-gates.md create mode 100644 docs/rfcs/0000-rfc-process.md create mode 100644 docs/rfcs/0001-product-and-platform-contract.md create mode 100644 docs/rfcs/0002-declarative-core-and-rendering.md create mode 100644 docs/rfcs/0003-text-semantics-and-input.md create mode 100644 docs/rfcs/0004-platform-seams-and-backend-truth.md create mode 100644 docs/rfcs/0005-quality-and-release-contract.md create mode 100644 docs/rfcs/README.md create mode 100755 tools/check-contracts.sh create mode 100644 zag.mod diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..22ee7a1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.zag text eol=lf +*.md text eol=lf +*.json text eol=lf +*.sh text eol=lf diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 0000000..a82f7f1 --- /dev/null +++ b/.github/workflows/contracts.yml @@ -0,0 +1,20 @@ +name: Repository contracts + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out source + uses: actions/checkout@v6 + - name: Validate product contracts + run: ./tools/check-contracts.sh + - name: Reject whitespace errors + run: git show --check --format= HEAD diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b893d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Zag build products +zag-out/ +.zag-cache/ +.zagd.lock + +# Contract and benchmark output +artifacts/ +benchmark-results/ +capability-reports/ + +# Platform build products +.DS_Store +Thumbs.db +*.app/ +*.apk +*.aab +*.ipa +*.msix +*.dmg diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7243729 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +Zagkit follows Semantic Versioning and keeps unreleased work under the heading +below. Public preview labels do not imply platform support. Every release must +include a generated capability record and exact Zag compiler revision. + +## Unreleased + +### Added + +- Milestone 0 product, architecture, quality, and dependency contracts. +- Machine readable platform, component, benchmark, compiler, and upstream + prerequisite records. +- An executable repository contract gate. + +## 0.1.0-experimental.0 (unreleased) + +The repository contract seed. No runtime or platform backend is available. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0bd23c5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing to Zagkit + +Zagkit accepts narrowly scoped, evidence backed changes that preserve the +architecture and support claims in this repository. + +## Before writing code + +1. Read [GOVERNANCE.md](GOVERNANCE.md), [DEPENDENCIES.md](DEPENDENCIES.md), and + the [accepted RFCs](docs/rfcs/README.md). +2. Check [the upstream ledger](contracts/upstream-zag.json). A reusable compiler, + ABI, concurrency, package, or platform defect belongs in Zag first. +3. Check the visual direction gate. Visual component production cannot begin + until [the design review](docs/design/visual-direction.md) is accepted. +4. Add or update the smallest contract, test, benchmark, or conformance scene + that can prove the change. + +## Change requirements + +- Production implementation is Zag. Do not add a C, C++, Zig, Rust, browser, + WebView, or foreign toolkit fallback to make a missing Zag feature disappear. +- Do not preserve a workaround merely because it would be conventional in + another language ecosystem. Improve Zag until the direct design is possible. +- Public OS APIs are permitted only at the platform seams listed in + [DEPENDENCIES.md](DEPENDENCIES.md). +- A backend or feature may be `supported`, `experimental`, or `unavailable`. + Supported and experimental states require named executable evidence. +- GPU work on hardware must be explicitly selected, bounded, and recoverable. + Discovery, compilation, screenshots, or CPU output do not certify GPU use. +- Tests must include cleanup and negative behavior where resources, malformed + input, callbacks, device loss, or platform boundaries are involved. +- Keep unrelated worktree changes out of the commit. + +An upstream fix is complete only when it is committed separately in +`/home/micah/Desktop/Sylorlabs/zag`, carries source and native executable +conformance there, and the affected Zagkit gate passes against that exact Zag +commit. A local Zag working tree, patched compiler binary, or downstream-only +test is not a dependency revision. + +## Local gate + +```sh +./tools/check-contracts.sh +git diff --check +``` + +Later milestones add compiler, unit, fuzz, golden, live platform, performance, +accessibility, and packaging gates. A green contract check does not substitute +for any of them. + +## RFCs + +Use [RFC 0000](docs/rfcs/0000-rfc-process.md) for changes to public API, +architecture, dependency boundaries, backend truth, or release gates. An RFC +records a decision. It is not implementation evidence. diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md new file mode 100644 index 0000000..5648390 --- /dev/null +++ b/DEPENDENCIES.md @@ -0,0 +1,59 @@ +# Dependency and ownership boundary + +Zagkit is built in Zag and owns the product layers above public operating system +and driver seams. This policy is normative. + +## Zagkit owned layers + +- state, bindings, actions, environment, view context, and stable node identity; +- reconciliation, layout, invalidation reasons, hit testing, and focus; +- semantics and automation trees; +- input routing, gestures, commands, animation, and frame scheduling; +- Unicode processing, OpenType shaping, font fallback, editing, and glyph + rasterization; +- render IR, immutable display lists, deterministic CPU rendering, GPU resource + ownership, and backend recovery; +- components, design tokens, adaptive behavior, inspectors, preview, reload, + snapshots, and command line tools. + +## Allowed operating system seams + +Public OS and GPU APIs may provide lifecycle, windows, surfaces, vsync, GPU +submission, IME, accessibility, clipboard, drag and drop, system menus, haptics, +notifications, and packaging. System fonts plus published Unicode and OpenType +data are allowed inputs. + +These seams must be isolated behind Zag interfaces, report capability truth, +clean up resources, and have native conformance tests. A platform seam does not +own Zagkit's view tree, layout, semantics, text model, or renderer architecture. + +## Prohibited runtime architecture + +The core and platform product may not depend on: + +- native widget proxy frameworks; +- browser or WebView runtimes; +- Skia, Flutter, Qt, FreeType, HarfBuzz, or another UI, text, or render engine; +- private Apple APIs; +- an LLVM based renderer; +- a C, C++, Zig, Rust, or other foreign implementation shim used to hide a Zag + compiler or runtime gap. + +Host tools used only to package, sign, drive a platform SDK, or validate +repository metadata are not runtime dependencies. They must be declared, +versioned where practical, and cannot implement product behavior. + +## Source first rule + +A reusable Zag language, compiler, ABI, concurrency, package, platform, or +runtime defect is fixed in the Zag repository with native conformance before +Zagkit consumes it. Zagkit may keep a failing reproducer and dependency record, +but not a permanent workaround. The upstream change is checked in separately, +and Zagkit pins the exact proven commit before relying on it. Patterns that +would normally be hidden behind a shim, reduced feature, unsafe escape, or +ecosystem workaround in another language are still Zag defects to solve at the +source. The authoritative queue is +[contracts/upstream-zag.json](contracts/upstream-zag.json). + +Vendor driver replacement can remain experimental and does not block 1.0. +Supported public GPU transports and the CPU visual oracle remain authoritative. diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..bd0a10f --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,49 @@ +# Governance + +## Project ownership + +Zagkit is a first party Sylor Labs project. Maintainers are responsible for the +public API, repository releases, accepted RFCs, support claims, security +response, and the relationship with Zag and first party consumers. + +The project uses reviewable pull requests for normative and implementation +changes. Direct emergency fixes must receive follow up review and evidence. + +## Decision classes + +| Change | Required record | +|---|---| +| Typo, test repair, internal refactor | pull request and relevant gates | +| Public API or behavior | RFC, compatibility note, tests | +| New runtime dependency or OS seam | RFC and dependency boundary review | +| Platform status promotion | native evidence bundle and capability record | +| Performance waiver over 5 percent | explicit reviewed waiver with expiry | +| Stable release or 1.0 gate change | maintainer approval and release audit | + +Accepted RFCs are normative until superseded. Code that conflicts with an +accepted RFC is a defect unless a newer RFC explicitly changes the decision. + +## Support claims + +Only evidence can promote a capability. Marketing copy, a code path, a compile, +a screenshot, emulation, or a provider report is not native runtime proof. +Capability changes must identify the device, operating system, compiler +revision, backend selected, executable gate, and cleanup result. + +No individual platform is named Zagkit 1.0. The product reaches 1.0 only when +Linux, macOS, Windows, iOS, and Android all satisfy the common release contract. + +## Upstream ownership + +Reusable language, compiler, ABI, package, concurrency, platform, or runtime +work is implemented and tested in Zag. Zagkit may carry a temporary failing +conformance test that demonstrates the need, but it may not hide the defect +behind a foreign language shim or permanent local workaround. The upstream fix +lands as its own reviewable Zag commit with native conformance; the downstream +change then pins that exact commit and proves the consumer path. + +## Conduct and security + +Contributors must be direct, respectful, and evidence led. Security reports +follow [SECURITY.md](SECURITY.md); do not publish exploit details before a fix +and coordinated disclosure are ready. diff --git a/README.md b/README.md index dca9fb6..15daa30 100644 --- a/README.md +++ b/README.md @@ -1 +1,67 @@ -# zagkit \ No newline at end of file +# Zagkit + +Zagkit is a first party application platform and UI toolkit built in Zag. It is +being designed for one adaptive product codebase across Linux, macOS, Windows, +iOS, and Android, with precise text, fluid interaction, built in semantics, and +inspectable runtime truth. + +This repository is at **0.1.0-experimental.0**. It currently contains the +accepted product contract and executable Milestone 0 checks. It does not yet +contain a usable renderer, window shell, component library, or supported +platform backend. Nothing in this repository is a Zagkit 1.0 release. + +## What Zagkit owns + +Zagkit will own the declarative view model, state tracking, reconciliation, +layout, semantics, input, animation, text, display lists, CPU rendering, +components, and developer tools. Public operating system APIs are narrow seams +for lifecycle, surfaces, input methods, accessibility, GPU submission, and +packaging. They do not define Zagkit's architecture. + +The deterministic CPU renderer will be the visual oracle. Metal, D3D12, and +public Linux and Android GPU APIs will carry a Zag owned render IR. Backend +selection, fallback, and device loss will always be observable through a +capability record. + +Zagkit is not a wrapper around native widgets and will not ship Skia, Flutter, +Qt, FreeType, HarfBuzz, a browser, or a WebView as its UI engine. System fonts +and published Unicode and OpenType data are allowed inputs. The exact boundary +is normative in [DEPENDENCIES.md](DEPENDENCIES.md). + +## Status + +| Area | Current state | Proof | +|---|---|---| +| Product and architecture contract | accepted | [RFC index](docs/rfcs/README.md) | +| Compiler dependency | pinned, prerequisites incomplete | [toolchain lock](contracts/toolchain.json) | +| Platform shells | unavailable | [support matrix](SUPPORT.md) | +| Headless core | not implemented | [roadmap](ROADMAP.md) | +| Components and visual language | inventory only, visual review pending | [component inventory](contracts/components.json) | +| Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | + +Run the repository contract gate with: + +```sh +./tools/check-contracts.sh +``` + +The gate validates the release identity, exact Zag revision, required platform +families, backend truth states, upstream prerequisite ledger, component +inventory, benchmark scene coverage, and the 1.0 block. + +## Build order + +1. Advance reusable compiler, ABI, concurrency, package, and platform features + in [Zag](https://github.com/Sylorlabs/zag), each with native executable proof. +2. Build Zagkit's deterministic headless core. +3. Ship an honest Linux preview, then prove the architecture in PrismStudio. +4. Reach desktop parity on macOS and Windows. +5. Reach mobile parity on iOS and Android. +6. Call the shared product 1.0 only after all five families pass the same gate. + +See [ROADMAP.md](ROADMAP.md) for milestone exit conditions and +[CONTRIBUTING.md](CONTRIBUTING.md) before proposing implementation work. + +## License + +Apache License 2.0. See [LICENSE](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..3d20a50 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,91 @@ +# Delivery roadmap + +The milestones are ordered by evidence dependency. Public previews may ship +incrementally. A milestone is complete only when every exit condition is linked +to executable evidence. + +## 0. Product contract + +**Status: implemented in this branch, pending review.** + +- Separate Apache 2.0 repository and governance. +- Semantic version and honest experimental label. +- Accepted architecture, dependency, backend truth, semantics, and release + RFCs. +- Machine readable platform matrix, component inventory, benchmark scene + inventory, compiler pin, and upstream Zag prerequisite ledger. +- Executable contract validation in CI. +- Explicit visual direction and accessibility review gate. + +This milestone creates no runtime support. Its completion cannot promote any +platform capability. + +## 1. Advance Zag + +**Status: blocked by the entries in +[contracts/upstream-zag.json](contracts/upstream-zag.json).** + +Upstream native object targets and ABI seams for Darwin, Windows, iOS, Android, +Objective C, COM, JNI, callbacks, and aggregates. Add resource embedding, +dynamic platform loading, event loop and worker primitives, package resolution, +incremental compilation, and reload hooks. Every slice requires a Zag source +regression, native executable conformance on its target, cleanup evidence, and +affected Zagkit conformance. + +## 2. Headless core + +**Status: not started.** + +Implement state dependencies, reconciliation, layout, semantics, immutable +display lists, deterministic CPU rasterization, Unicode and OpenType text, +animation clocks, input routing, hit testing, snapshots, and deterministic +replay without a window system. Exit requires unit, property, fuzz, +differential, golden, and replay gates. + +## 3. Linux preview + +**Status: not started.** + +Implement Wayland first with X11 fallback, multi window and monitor handling, +fractional scaling, clipboard, IME, AT-SPI, CPU presentation, and one explicit +public GPU transport. Exit requires live native execution and fail closed +capability reporting on Ubuntu LTS and current Fedora baselines for x86-64 and +ARM64 where the compiler target exists. + +## 4. PrismStudio dogfood + +**Status: not started.** + +Migrate PrismStudio as the first complex consumer. Preserve CAD workflows, +direct manipulation, tables, menus, keyboard control, viewport behavior, +performance evidence, and its explicit physical GPU safety boundary. The +component gallery remains a conformance surface; PrismStudio is the product +proof. + +## 5. Desktop parity + +**Status: not started.** + +Add macOS and Windows shells, desktop adaptation, native IME and accessibility, +menus, clipboard, drag and drop, packaging, signing, Metal, and D3D12. Public +betas require device execution with VoiceOver and Narrator plus recovery and +packaging gates. + +## 6. Mobile parity + +**Status: not started.** + +Add iOS and Android shells, lifecycle restoration, rotation, safe areas, +touch-first navigation, gestures, soft keyboard, haptics, mobile accessibility, +Metal and Android GPU transports, packaging, and a focused reference app. +Public betas require device execution with VoiceOver and TalkBack. + +## 7. All platform 1.0 + +**Status: blocked.** + +Freeze the initial API only after the shared component suite, PrismStudio, +mobile reference app, international text, accessibility, recovery, performance, +native packaging, and reproducible release gates pass across Linux, macOS, +Windows, iOS, and Android. Unsupported and untested hardware stays visibly +unavailable. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..68c8623 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security policy + +Zagkit is pre release software and currently has no supported runtime. Security +issues in repository automation, specifications, or future implementation +should be reported privately through the Sylor Labs GitHub organization contact +or GitHub private vulnerability reporting when it is enabled for this +repository. + +Please include the affected revision, platform, backend, reproduction, impact, +and whether the issue crosses a process, GPU, accessibility, input, font, or +package boundary. Do not include secrets or destructive proof when a bounded +reproduction is sufficient. + +No version is currently eligible for security support. A beta or stable release +will replace this paragraph with its exact support window and response policy. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..8ea6de4 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,49 @@ +# Platform support + +Zagkit currently supports no runtime platform. The repository is an +experimental product contract and all shell, renderer, accessibility, text +input, GPU, and packaging capabilities are `unavailable`. + +The machine readable authority is +[contracts/platforms.json](contracts/platforms.json). Future release artifacts +will contain a generated instance of the same capability model with one of +three states: + +- `supported`: the feature passed its complete native gate on a named target. +- `experimental`: executable evidence exists, but the support gate is + incomplete and the missing condition is named. +- `unavailable`: the backend did not activate or has no qualifying evidence; + the reason is mandatory. + +`.auto` is a selection request, not a capability. A runtime using `.auto` must +record the backend actually activated. Device loss and fallback append +observable events and update the capability record. + +## Intended beta policy + +When a backend first enters beta, its matrix freezes exact OS versions and +reference devices: + +| Family | Intended policy | Current Zagkit state | +|---|---|---| +| Linux | current Ubuntu LTS and current Fedora releases on x86-64 and ARM64 | unavailable | +| macOS | current and previous two stable major releases | unavailable | +| Windows | current and previous two supported stable releases | unavailable | +| iOS | current and previous two stable major releases | unavailable | +| Android | current and previous two stable API generations | unavailable | + +Version names are deliberately not frozen before beta. Recording a date based +guess here would look precise while providing no tested support. Every beta and +stable release instead records the exact versions used by its native evidence. + +Web, browser rendering, and WebView hosting are outside the 1.0 scope. + +## Promotion gate + +A capability cannot move out of `unavailable` based on a code path, +cross-compile, screenshot, emulator, or API discovery. Promotion requires the +target device, exact OS and compiler revisions, executable test, selected +backend, assistive technology where relevant, cleanup, and recovery result. + +The whole product remains pre-1.0 until all five required platform families +pass [the release gates](docs/quality/release-gates.md). diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..8c97348 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,46 @@ +# Versioning and release channels + +Zagkit uses Semantic Versioning 2.0.0. + +## Version meaning before 1.0 + +- `0.MINOR.PATCH-experimental.N` may change any API and may have no usable + platform backend. +- `0.MINOR.PATCH-alpha.N` provides coherent end to end slices for named + platforms, but important APIs and recovery behavior remain incomplete. +- `0.MINOR.PATCH-beta.N` freezes the intended API for named platforms and has + native accessibility, text input, packaging, recovery, and performance + evidence. Defects can still block production use. +- `1.0.0` requires the common gate on all five platform families. There is no + platform specific early 1.0. + +A release channel describes maturity, not capability. The generated capability +record remains authoritative. + +## Compatibility + +Before 1.0, breaking API changes require a changelog entry and migration note. +After 1.0, public source API compatibility follows SemVer. Serialization, +replay, snapshot, render IR, plugin, and binary compatibility each remain +separately versioned contracts and must not be inferred from the package +version. + +The Zag ABI is currently unstable. Every Zagkit release pins an exact Zag +commit and edition in [contracts/toolchain.json](contracts/toolchain.json). +Consumers rebuild Zagkit and its Zag dependencies with that toolchain unless a +release explicitly proves a compatible range. + +## Release contents + +Every release must include: + +- exact Zagkit and Zag revisions; +- backend capability record, including `.auto` selections and fallbacks; +- reference hardware and operating system versions; +- benchmark and quality gate results; +- known unsupported features and untested hardware; +- reproducible build inputs and artifact hashes; +- packaging install, launch, update, and uninstall evidence where applicable. + +Until the release automation can produce this evidence, releases remain +experimental and source only. diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..9d2564b --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,19 @@ +# Benchmark scene specifications + +[benchmark-scenes.json](../contracts/benchmark-scenes.json) defines the first +canonical workloads and global performance contract. These are specifications, +not benchmark implementations or results. + +Every implemented scene will have: + +- deterministic seed and replay stream; +- declared variant coverage; +- reference output or semantic assertions; +- warmup, duration, and sample policy; +- frame reason, CPU, GPU, memory, allocation, resource, and input latency traces; +- exact Zagkit, Zag, OS, device, driver, display, and power metadata; +- cleanup and idle observation periods. + +Raw run artifacts live outside source control until reviewed. Published summary +data links to immutable raw evidence. A missing metric stays missing rather than +being encoded as zero. No single screenshot or one-off timing is a result. diff --git a/contracts/benchmark-scenes.json b/contracts/benchmark-scenes.json new file mode 100644 index 0000000..0e69aaf --- /dev/null +++ b/contracts/benchmark-scenes.json @@ -0,0 +1,116 @@ +{ + "schema_version": 1, + "generated_for": "0.1.0-experimental.0", + "result_state": "specifications-only", + "warning": "No performance result is recorded or implied by this manifest.", + "variant_matrix": { + "scale_factors": [1.0, 1.25, 1.5, 2.0, 3.0], + "themes": ["light", "dark"], + "contrast": ["standard", "high"], + "directions": ["ltr", "rtl"], + "text_scales": [1.0, 1.3, 2.0], + "motion": ["full", "reduced"], + "locales": ["en-US", "ar-EG", "he-IL", "hi-IN", "ja-JP", "th-TH", "zh-Hant-TW"] + }, + "global_performance_contract": { + "frame_deadline_p99_fraction_max": 1.0, + "scroll_refresh_hz_on_supported_reference_hardware": 120, + "idle_layouts_per_second_max": 0, + "idle_paints_per_second_max": 0, + "unexplained_two_frame_stalls_per_ten_minutes_max": 0, + "regression_without_reviewed_waiver_percent_max": 5.0, + "canonical_run_minutes": 10 + }, + "scenes": [ + { + "id": "type-ramp", + "name": "International type ramp", + "milestone": 2, + "status": "specification", + "canonical_interaction": "Resize through breakpoints while changing text scale and locale.", + "stresses": ["font-fallback", "variable-fonts", "color-fonts", "line-breaking", "intrinsic-layout"], + "assertions": ["deterministic-golden", "baseline-stability", "no-missing-glyph-with-declared-fonts", "semantic-reading-order"] + }, + { + "id": "bidi-editor", + "name": "Bidirectional text editor", + "milestone": 2, + "status": "specification", + "canonical_interaction": "Compose, select, replace, undo, and navigate mixed Arabic, Hebrew, Latin, emoji, and combining text.", + "stresses": ["bidi", "segmentation", "ime", "selection", "editing", "malformed-input"], + "assertions": ["caret-roundtrip", "selection-stability", "ime-commit", "text-navigation-semantics", "deterministic-replay"] + }, + { + "id": "million-row-list", + "name": "Million row virtual list", + "milestone": 2, + "status": "specification", + "canonical_interaction": "Fling, reverse, resize, select, and keyboard navigate a variable height million row list.", + "stresses": ["virtualization", "scrolling", "reconciliation", "measurement", "focus"], + "assertions": ["120hz-reference-scroll", "stable-node-identity", "bounded-live-nodes", "semantic-range", "no-idle-work"] + }, + { + "id": "gesture-handoff", + "name": "Nested gesture handoff", + "milestone": 2, + "status": "specification", + "canonical_interaction": "Transfer touch and pen motion among nested scroll, drag, zoom, and edge navigation recognizers.", + "stresses": ["gesture-arbitration", "velocity", "touch", "pen", "nested-scroll"], + "assertions": ["continuous-position", "single-winner", "cancellation-cleanup", "deterministic-replay"] + }, + { + "id": "interruptible-transitions", + "name": "Interruptible transition lab", + "milestone": 2, + "status": "specification", + "canonical_interaction": "Interrupt, reverse, resize, and hand off spring, keyframe, layout, and shared transitions.", + "stresses": ["animation", "layout-transition", "shared-transition", "gesture-handoff", "resize"], + "assertions": ["position-continuity", "velocity-continuity", "reduced-motion-substitution", "deterministic-replay", "no-two-frame-stall"] + }, + { + "id": "adaptive-dashboard", + "name": "Adaptive dashboard", + "milestone": 2, + "status": "specification", + "canonical_interaction": "Resize continuously across compact, medium, and expanded layouts in LTR and RTL.", + "stresses": ["grid", "flex", "breakpoints", "safe-areas", "rtl", "large-text"], + "assertions": ["no-overflow", "focus-order", "exact-invalidation-reason", "semantic-order", "no-idle-work"] + }, + { + "id": "semantic-form", + "name": "Semantic settings form", + "milestone": 2, + "status": "specification", + "canonical_interaction": "Complete validation, errors, help, ranges, live updates, and grouped controls with keyboard and automation only.", + "stresses": ["semantics", "focus", "live-regions", "forms", "large-text"], + "assertions": ["role-name-value", "action-parity", "focus-order", "live-region-order", "screen-reader-script"] + }, + { + "id": "cad-viewport", + "name": "PrismStudio viewport and chrome", + "milestone": 4, + "status": "specification", + "canonical_interaction": "Orbit, pan, zoom, select, edit properties, use menus, and scrub a dense CAD scene.", + "stresses": ["viewport", "canvas", "tables", "menus", "direct-manipulation", "gpu-transport"], + "assertions": ["input-latency", "cpu-oracle-tolerance", "backend-event-log", "keyboard-equivalence", "resource-cleanup"] + }, + { + "id": "multi-window-scale", + "name": "Multi window scale migration", + "milestone": 3, + "status": "specification", + "canonical_interaction": "Create, resize, move, suspend, restore, and close windows across monitors with different scale and refresh rates.", + "stresses": ["multi-window", "multi-monitor", "fractional-scale", "surface-loss", "refresh-rate"], + "assertions": ["physical-size-continuity", "surface-recovery", "backend-event-log", "resource-cleanup", "no-idle-work"] + }, + { + "id": "layer-effects", + "name": "Layer and effects stress", + "milestone": 2, + "status": "specification", + "canonical_interaction": "Animate clipped transformed layers with paths, glyphs, images, opacity, blur, and shadows.", + "stresses": ["display-list", "clipping", "transforms", "layers", "effects", "rasterization"], + "assertions": ["deterministic-golden", "cpu-oracle", "gpu-tolerance", "bounded-resources", "damage-correctness"] + } + ] +} diff --git a/contracts/components.json b/contracts/components.json new file mode 100644 index 0000000..8807b3d --- /dev/null +++ b/contracts/components.json @@ -0,0 +1,68 @@ +{ + "schema_version": 1, + "generated_for": "0.1.0-experimental.0", + "status_vocabulary": ["planned", "implementing", "experimental", "conformant"], + "conformance_dimensions": [ + "semantics", + "keyboard", + "pointer", + "touch", + "focus", + "rtl", + "large_text", + "high_contrast", + "reduced_motion", + "snapshot" + ], + "visual_direction_gate": { + "state": "blocked", + "decision": "docs/design/visual-direction.md", + "rule": "Visual component production cannot begin until visual direction and accessibility review are accepted." + }, + "components": [ + {"id":"text","family":"content","name":"Text","milestone":2,"status":"planned","semantic_roles":["text"],"inputs":[],"adaptive":["locale","direction","text_scale"]}, + {"id":"rich-text","family":"content","name":"RichText","milestone":2,"status":"planned","semantic_roles":["text","link"],"inputs":["pointer","keyboard","touch"],"adaptive":["locale","direction","text_scale"]}, + {"id":"image","family":"content","name":"Image","milestone":2,"status":"planned","semantic_roles":["image"],"inputs":[],"adaptive":["density","contrast"]}, + {"id":"icon","family":"content","name":"Icon","milestone":2,"status":"planned","semantic_roles":["image","none"],"inputs":[],"adaptive":["density","contrast","direction"]}, + {"id":"divider","family":"content","name":"Divider","milestone":2,"status":"planned","semantic_roles":["separator"],"inputs":[],"adaptive":["density","contrast","direction"]}, + {"id":"button","family":"actions","name":"Button","milestone":2,"status":"planned","semantic_roles":["button"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","contrast","reduced_motion"]}, + {"id":"toggle","family":"actions","name":"Toggle","milestone":2,"status":"planned","semantic_roles":["switch"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","contrast","reduced_motion"]}, + {"id":"checkbox","family":"actions","name":"Checkbox","milestone":2,"status":"planned","semantic_roles":["checkbox"],"inputs":["pointer","keyboard","touch"],"adaptive":["density","platform","contrast"]}, + {"id":"radio","family":"actions","name":"RadioGroup","milestone":2,"status":"planned","semantic_roles":["radio","radiogroup"],"inputs":["pointer","keyboard","touch"],"adaptive":["density","platform","direction"]}, + {"id":"slider","family":"actions","name":"Slider","milestone":2,"status":"planned","semantic_roles":["slider"],"inputs":["pointer","keyboard","touch","pen","gamepad"],"adaptive":["density","platform","direction","reduced_motion"]}, + {"id":"stepper","family":"actions","name":"Stepper","milestone":2,"status":"planned","semantic_roles":["spinbutton"],"inputs":["pointer","keyboard","touch"],"adaptive":["density","platform","direction"]}, + {"id":"progress","family":"feedback","name":"Progress","milestone":2,"status":"planned","semantic_roles":["progressbar"],"inputs":[],"adaptive":["contrast","direction","reduced_motion"]}, + {"id":"activity","family":"feedback","name":"ActivityIndicator","milestone":2,"status":"planned","semantic_roles":["status"],"inputs":[],"adaptive":["contrast","reduced_motion"]}, + {"id":"badge","family":"feedback","name":"Badge","milestone":2,"status":"planned","semantic_roles":["status"],"inputs":[],"adaptive":["density","locale","direction","text_scale"]}, + {"id":"text-field","family":"input","name":"TextField","milestone":2,"status":"planned","semantic_roles":["textbox"],"inputs":["pointer","keyboard","touch","pen","ime"],"adaptive":["density","platform","locale","direction","text_scale"]}, + {"id":"secure-field","family":"input","name":"SecureField","milestone":2,"status":"planned","semantic_roles":["textbox"],"inputs":["pointer","keyboard","touch","ime"],"adaptive":["density","platform","locale","direction","text_scale"]}, + {"id":"text-editor","family":"input","name":"TextEditor","milestone":2,"status":"planned","semantic_roles":["textbox"],"inputs":["pointer","keyboard","touch","pen","ime"],"adaptive":["density","platform","locale","direction","text_scale"]}, + {"id":"search-field","family":"input","name":"SearchField","milestone":2,"status":"planned","semantic_roles":["searchbox"],"inputs":["pointer","keyboard","touch","ime"],"adaptive":["density","platform","locale","direction","text_scale"]}, + {"id":"picker","family":"input","name":"Picker","milestone":2,"status":"planned","semantic_roles":["combobox","listbox"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","direction","text_scale"]}, + {"id":"color-picker","family":"input","name":"ColorPicker","milestone":4,"status":"planned","semantic_roles":["group","slider"],"inputs":["pointer","keyboard","touch","pen"],"adaptive":["density","platform","contrast"]}, + {"id":"link","family":"navigation","name":"Link","milestone":2,"status":"planned","semantic_roles":["link"],"inputs":["pointer","keyboard","touch"],"adaptive":["platform","contrast","direction"]}, + {"id":"tabs","family":"navigation","name":"Tabs","milestone":2,"status":"planned","semantic_roles":["tab","tablist","tabpanel"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","direction","text_scale"]}, + {"id":"navigation-stack","family":"navigation","name":"NavigationStack","milestone":2,"status":"planned","semantic_roles":["navigation"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["breakpoint","platform","direction","reduced_motion"]}, + {"id":"split-view","family":"navigation","name":"SplitView","milestone":2,"status":"planned","semantic_roles":["navigation","separator"],"inputs":["pointer","keyboard","touch"],"adaptive":["breakpoint","platform","direction","text_scale"]}, + {"id":"toolbar","family":"navigation","name":"Toolbar","milestone":2,"status":"planned","semantic_roles":["toolbar"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","direction","text_scale"]}, + {"id":"list","family":"collections","name":"List","milestone":2,"status":"planned","semantic_roles":["list","listitem"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","direction","text_scale"]}, + {"id":"virtual-list","family":"collections","name":"VirtualList","milestone":2,"status":"planned","semantic_roles":["list","listitem"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","direction","text_scale"]}, + {"id":"table","family":"collections","name":"Table","milestone":2,"status":"planned","semantic_roles":["table","row","columnheader","cell"],"inputs":["pointer","keyboard","touch"],"adaptive":["density","platform","direction","text_scale"]}, + {"id":"tree","family":"collections","name":"Tree","milestone":2,"status":"planned","semantic_roles":["tree","treeitem"],"inputs":["pointer","keyboard","touch"],"adaptive":["density","platform","direction","text_scale"]}, + {"id":"grid","family":"collections","name":"Grid","milestone":2,"status":"planned","semantic_roles":["grid","row","gridcell"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","breakpoint","direction","text_scale"]}, + {"id":"dialog","family":"overlays","name":"Dialog","milestone":2,"status":"planned","semantic_roles":["dialog"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["breakpoint","platform","direction","text_scale","reduced_motion"]}, + {"id":"sheet","family":"overlays","name":"Sheet","milestone":2,"status":"planned","semantic_roles":["dialog"],"inputs":["pointer","keyboard","touch","gesture"],"adaptive":["breakpoint","platform","direction","reduced_motion"]}, + {"id":"popover","family":"overlays","name":"Popover","milestone":2,"status":"planned","semantic_roles":["dialog"],"inputs":["pointer","keyboard","touch"],"adaptive":["breakpoint","platform","direction","text_scale"]}, + {"id":"tooltip","family":"overlays","name":"Tooltip","milestone":2,"status":"planned","semantic_roles":["tooltip"],"inputs":["pointer","keyboard"],"adaptive":["platform","direction","text_scale","reduced_motion"]}, + {"id":"menu","family":"overlays","name":"Menu","milestone":2,"status":"planned","semantic_roles":["menu","menuitem"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","direction","text_scale"]}, + {"id":"context-menu","family":"overlays","name":"ContextMenu","milestone":2,"status":"planned","semantic_roles":["menu","menuitem"],"inputs":["pointer","keyboard","touch","pen"],"adaptive":["density","platform","direction","text_scale"]}, + {"id":"stack","family":"layout","name":"Stack","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["density","direction","text_scale"]}, + {"id":"flex","family":"layout","name":"Flex","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["density","breakpoint","direction","text_scale"]}, + {"id":"overlay","family":"layout","name":"Overlay","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["direction","text_scale"]}, + {"id":"scroll","family":"layout","name":"Scroll","milestone":2,"status":"planned","semantic_roles":["group"],"inputs":["pointer","keyboard","touch","pen","wheel","gamepad"],"adaptive":["density","platform","direction","reduced_motion"]}, + {"id":"safe-area","family":"layout","name":"SafeArea","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["platform","orientation"]}, + {"id":"canvas","family":"advanced","name":"Canvas","milestone":2,"status":"planned","semantic_roles":["group"],"inputs":["pointer","keyboard","touch","pen"],"adaptive":["density","contrast","direction"]}, + {"id":"viewport","family":"advanced","name":"Viewport","milestone":4,"status":"planned","semantic_roles":["application","group"],"inputs":["pointer","keyboard","touch","pen","wheel","gamepad","gesture"],"adaptive":["density","platform","contrast","reduced_motion"]}, + {"id":"command-palette","family":"advanced","name":"CommandPalette","milestone":4,"status":"planned","semantic_roles":["dialog","searchbox","listbox"],"inputs":["pointer","keyboard","touch"],"adaptive":["density","platform","locale","direction","text_scale"]} + ] +} diff --git a/contracts/platforms.json b/contracts/platforms.json new file mode 100644 index 0000000..5fb9911 --- /dev/null +++ b/contracts/platforms.json @@ -0,0 +1,119 @@ +{ + "schema_version": 1, + "generated_for": "0.1.0-experimental.0", + "generated_on": "2026-08-06", + "status_vocabulary": ["supported", "experimental", "unavailable"], + "required_capabilities": [ + "platform_shell", + "cpu_renderer", + "gpu_transport", + "text_input", + "accessibility", + "clipboard_drag_drop", + "multi_window", + "packaging", + "auto_backend_selection" + ], + "platforms": [ + { + "id": "linux", + "family": "Linux", + "architectures": ["x86_64", "arm64"], + "beta_os_policy": "Current Ubuntu LTS and current Fedora releases when the backend enters beta.", + "toolchain_state": "Pinned Zag supports x86-64 Linux and marks ARM64 Linux experimental with qemu-user execution evidence; physical ARM64 target proof is not recorded.", + "public_channel": "experimental", + "capabilities": [ + {"id":"platform_shell","state":"unavailable","reason":"Wayland and X11 shells are not implemented.","evidence":[]}, + {"id":"cpu_renderer","state":"unavailable","reason":"The deterministic CPU renderer is not implemented.","evidence":[]}, + {"id":"gpu_transport","state":"unavailable","reason":"No Zagkit Linux GPU transport exists or has device evidence.","evidence":[]}, + {"id":"text_input","state":"unavailable","reason":"No Linux IME bridge or editing engine exists.","evidence":[]}, + {"id":"accessibility","state":"unavailable","reason":"No AT-SPI adapter exists or has assistive technology evidence.","evidence":[]}, + {"id":"clipboard_drag_drop","state":"unavailable","reason":"Clipboard and drag and drop seams are not implemented.","evidence":[]}, + {"id":"multi_window","state":"unavailable","reason":"Window, monitor, and scale lifecycle support is not implemented.","evidence":[]}, + {"id":"packaging","state":"unavailable","reason":"No installable Linux artifact or packaging gate exists.","evidence":[]}, + {"id":"auto_backend_selection","state":"unavailable","reason":"There are no eligible Zagkit backends for .auto to select.","evidence":[]} + ] + }, + { + "id": "macos", + "family": "macOS", + "architectures": ["arm64", "x86_64"], + "beta_os_policy": "Current and previous two stable major releases when the backend enters beta.", + "toolchain_state": "The pinned Zag revision has no Mach-O or Darwin target.", + "public_channel": "experimental", + "capabilities": [ + {"id":"platform_shell","state":"unavailable","reason":"The AppKit shell and Darwin target are not implemented.","evidence":[]}, + {"id":"cpu_renderer","state":"unavailable","reason":"The deterministic CPU renderer is not implemented.","evidence":[]}, + {"id":"gpu_transport","state":"unavailable","reason":"No Zagkit Metal transport exists or has device evidence.","evidence":[]}, + {"id":"text_input","state":"unavailable","reason":"No NSTextInputClient bridge or editing engine exists.","evidence":[]}, + {"id":"accessibility","state":"unavailable","reason":"No NSAccessibility adapter exists or has VoiceOver evidence.","evidence":[]}, + {"id":"clipboard_drag_drop","state":"unavailable","reason":"Clipboard and drag and drop seams are not implemented.","evidence":[]}, + {"id":"multi_window","state":"unavailable","reason":"Window, screen, and scale lifecycle support is not implemented.","evidence":[]}, + {"id":"packaging","state":"unavailable","reason":"No signed, notarized, installable artifact or packaging gate exists.","evidence":[]}, + {"id":"auto_backend_selection","state":"unavailable","reason":"There are no eligible Zagkit backends for .auto to select.","evidence":[]} + ] + }, + { + "id": "windows", + "family": "Windows", + "architectures": ["x86_64", "arm64"], + "beta_os_policy": "Current and previous two supported stable releases when the backend enters beta.", + "toolchain_state": "The pinned Zag revision has no PE/COFF or Windows target.", + "public_channel": "experimental", + "capabilities": [ + {"id":"platform_shell","state":"unavailable","reason":"The Win32 shell and Windows target are not implemented.","evidence":[]}, + {"id":"cpu_renderer","state":"unavailable","reason":"The deterministic CPU renderer is not implemented.","evidence":[]}, + {"id":"gpu_transport","state":"unavailable","reason":"No Zagkit D3D12 transport exists or has device evidence.","evidence":[]}, + {"id":"text_input","state":"unavailable","reason":"No Core Text input bridge or editing engine exists.","evidence":[]}, + {"id":"accessibility","state":"unavailable","reason":"No UI Automation adapter exists or has Narrator evidence.","evidence":[]}, + {"id":"clipboard_drag_drop","state":"unavailable","reason":"Clipboard and drag and drop seams are not implemented.","evidence":[]}, + {"id":"multi_window","state":"unavailable","reason":"Window, display, and scale lifecycle support is not implemented.","evidence":[]}, + {"id":"packaging","state":"unavailable","reason":"No signed installable artifact or packaging gate exists.","evidence":[]}, + {"id":"auto_backend_selection","state":"unavailable","reason":"There are no eligible Zagkit backends for .auto to select.","evidence":[]} + ] + }, + { + "id": "ios", + "family": "iOS", + "architectures": ["arm64"], + "beta_os_policy": "Current and previous two stable major releases when the backend enters beta.", + "toolchain_state": "The pinned Zag revision has no Mach-O, iOS ARM64, or Objective C ABI target.", + "public_channel": "experimental", + "capabilities": [ + {"id":"platform_shell","state":"unavailable","reason":"The UIKit shell and iOS target are not implemented.","evidence":[]}, + {"id":"cpu_renderer","state":"unavailable","reason":"The deterministic CPU renderer is not implemented.","evidence":[]}, + {"id":"gpu_transport","state":"unavailable","reason":"No Zagkit mobile Metal transport exists or has device evidence.","evidence":[]}, + {"id":"text_input","state":"unavailable","reason":"No UIKit text input bridge or editing engine exists.","evidence":[]}, + {"id":"accessibility","state":"unavailable","reason":"No UIKit accessibility adapter exists or has mobile VoiceOver evidence.","evidence":[]}, + {"id":"clipboard_drag_drop","state":"unavailable","reason":"Clipboard and drag and drop seams are not implemented.","evidence":[]}, + {"id":"multi_window","state":"unavailable","reason":"Scene, display, rotation, and safe area lifecycle support is not implemented.","evidence":[]}, + {"id":"packaging","state":"unavailable","reason":"No signed installable device artifact or packaging gate exists.","evidence":[]}, + {"id":"auto_backend_selection","state":"unavailable","reason":"There are no eligible Zagkit backends for .auto to select.","evidence":[]} + ] + }, + { + "id": "android", + "family": "Android", + "architectures": ["arm64"], + "beta_os_policy": "Current and previous two stable API generations when the backend enters beta.", + "toolchain_state": "The pinned Zag revision has no Android ARM64 or JNI ABI target.", + "public_channel": "experimental", + "capabilities": [ + {"id":"platform_shell","state":"unavailable","reason":"The Android shell and target are not implemented.","evidence":[]}, + {"id":"cpu_renderer","state":"unavailable","reason":"The deterministic CPU renderer is not implemented.","evidence":[]}, + {"id":"gpu_transport","state":"unavailable","reason":"No Zagkit Android GPU transport exists or has device evidence.","evidence":[]}, + {"id":"text_input","state":"unavailable","reason":"No Android IME bridge or editing engine exists.","evidence":[]}, + {"id":"accessibility","state":"unavailable","reason":"No Android accessibility adapter exists or has TalkBack evidence.","evidence":[]}, + {"id":"clipboard_drag_drop","state":"unavailable","reason":"Clipboard and drag and drop seams are not implemented.","evidence":[]}, + {"id":"multi_window","state":"unavailable","reason":"Activity, display, rotation, and safe area lifecycle support is not implemented.","evidence":[]}, + {"id":"packaging","state":"unavailable","reason":"No signed installable device artifact or packaging gate exists.","evidence":[]}, + {"id":"auto_backend_selection","state":"unavailable","reason":"There are no eligible Zagkit backends for .auto to select.","evidence":[]} + ] + } + ], + "one_point_zero": { + "blocked": true, + "required_platforms": ["linux", "macos", "windows", "ios", "android"], + "reason": "No required platform currently has a supported capability set." + } +} diff --git a/contracts/toolchain.json b/contracts/toolchain.json new file mode 100644 index 0000000..7b70953 --- /dev/null +++ b/contracts/toolchain.json @@ -0,0 +1,18 @@ +{ + "schema_version": 1, + "zagkit_version": "0.1.0-experimental.0", + "zag": { + "repository": "https://github.com/Sylorlabs/zag", + "resolved_ref": "refs/heads/zag-v2-machine-control", + "commit": "67cad46feb6a6d912b8d599d7ed3ade7e81175c5", + "compiler_version": "2026.07.0-dev", + "edition": "2026" + }, + "audited_on": "2026-08-06", + "pin_policy": "Every Zagkit release replaces this record with the exact Zag source commit used to build and test it. Branch names and local dirty state are never release identities.", + "evidence": [ + "Zag origin/zag-v2-machine-control resolved to 67cad46feb6a6d912b8d599d7ed3ade7e81175c5 on 2026-08-06.", + "zag-poc/zag.mod at the pinned commit declares version 2026.07.0-dev and edition 2026.", + "The pinned commit documents x86-64 Linux as supported and ARM64 Linux as experimental with qemu-user execution evidence." + ] +} diff --git a/contracts/upstream-zag.json b/contracts/upstream-zag.json new file mode 100644 index 0000000..5cabbf8 --- /dev/null +++ b/contracts/upstream-zag.json @@ -0,0 +1,130 @@ +{ + "schema_version": 1, + "audited_repository": "https://github.com/Sylorlabs/zag", + "audited_commit": "67cad46feb6a6d912b8d599d7ed3ade7e81175c5", + "audited_on": "2026-08-06", + "state_vocabulary": ["available", "partial", "missing"], + "evidence_rule": "Available requires native executable conformance at the audited commit. Source presence, a cross compile, or a dirty working tree does not qualify.", + "entries": [ + { + "id": "target-linux-arm64", + "category": "object-target", + "state": "partial", + "needed_by": [3, 7], + "evidence": "zag-poc/VERSIONING.md marks ARM64 Linux experimental and cites qemu-user execution, while unsupported numeric, debug, and call cases remain and no physical ARM64 evidence is recorded.", + "exit_gate": "Pure Zag ARM64 ELF generation, ABI corpus, native device execution, self-hosting or documented bootstrap boundary, and cleanup pass." + }, + { + "id": "target-darwin-macho", + "category": "object-target", + "state": "missing", + "needed_by": [5, 6, 7], + "evidence": "zag-poc/VERSIONING.md calls macOS not planned yet and requires a Mach-O backend.", + "exit_gate": "Mach-O x86-64 and ARM64 executables run natively on declared macOS versions with ABI and lifecycle conformance." + }, + { + "id": "target-windows-pe-coff", + "category": "object-target", + "state": "missing", + "needed_by": [5, 7], + "evidence": "zag-poc/VERSIONING.md calls Windows not planned and requires a PE/COFF backend.", + "exit_gate": "PE/COFF x86-64 and ARM64 executables run natively with Windows ABI, unwind, resource, and lifecycle conformance." + }, + { + "id": "target-ios-arm64", + "category": "object-target", + "state": "missing", + "needed_by": [6, 7], + "evidence": "No iOS target or native conformance suite exists at the pinned commit.", + "exit_gate": "Signed Zag executable code runs on physical iOS devices with lifecycle, ABI, and packaging conformance." + }, + { + "id": "target-android-arm64", + "category": "object-target", + "state": "missing", + "needed_by": [6, 7], + "evidence": "No Android target or native conformance suite exists at the pinned commit.", + "exit_gate": "Zag ARM64 output runs on physical Android devices with ELF, JNI, lifecycle, and packaging conformance." + }, + { + "id": "abi-objective-c", + "category": "platform-abi", + "state": "missing", + "needed_by": [5, 6, 7], + "evidence": "No Objective C runtime ABI implementation or native conformance exists at the pinned commit.", + "exit_gate": "Typed Objective C message sends, callbacks, ownership, exceptions boundary, and aggregate cases pass on macOS and iOS devices." + }, + { + "id": "abi-com", + "category": "platform-abi", + "state": "missing", + "needed_by": [5, 7], + "evidence": "No COM ABI implementation or native conformance exists at the pinned commit.", + "exit_gate": "IUnknown lifetime, interface calls, callbacks, HRESULTs, threading model, and aggregate cases pass on Windows." + }, + { + "id": "abi-jni", + "category": "platform-abi", + "state": "missing", + "needed_by": [6, 7], + "evidence": "No JNI ABI implementation or native conformance exists at the pinned commit.", + "exit_gate": "Java to Zag and Zag to Java calls, references, exceptions, threads, callbacks, and cleanup pass on Android devices." + }, + { + "id": "abi-callbacks", + "category": "foreign-abi", + "state": "partial", + "needed_by": [3, 5, 6, 7], + "evidence": "The pinned v2 ABI documents executable qsort evidence for one direct captureless scalar and pointer callback, while captures, returned callbacks, floats, aggregates, ownership, and unload contracts remain unsupported.", + "exit_gate": "Captureless and context callbacks across each supported platform ABI pass register, stack, lifetime, reentrancy, unwind boundary, and negative tests." + }, + { + "id": "abi-aggregates", + "category": "foreign-abi", + "state": "missing", + "needed_by": [3, 5, 6, 7], + "evidence": "No general foreign aggregate parameter and return ABI conformance exists at the pinned commit.", + "exit_gate": "Platform ABI suites pass small and large structs, unions, vectors, floats, alignment, return by value, and mixed register classes." + }, + { + "id": "resource-embedding", + "category": "build-system", + "state": "missing", + "needed_by": [3, 5, 6, 7], + "evidence": "No compiler owned resource embedding contract or conformance suite exists at the pinned commit.", + "exit_gate": "Deterministic byte resources embed into every supported object format with identity, alignment, lookup, reproducibility, and malformed input tests." + }, + { + "id": "dynamic-platform-loading", + "category": "runtime", + "state": "partial", + "needed_by": [3, 5, 6, 7], + "evidence": "zag-poc/docs/DYNAMIC_SYSTEM_ABI.md proves a narrow x86-64 Linux dynamic ELF import path with scalar outbound calls and one captureless callback; other targets, aggregates, unload, TLS, and general callbacks are unsupported.", + "exit_gate": "Typed library loading, symbol lookup, version failure, ownership, unload, callback, and aggregate tests pass per supported platform." + }, + { + "id": "main-loop-and-workers", + "category": "concurrency", + "state": "partial", + "needed_by": [2, 3, 5, 6, 7], + "evidence": "The pinned v2 concurrency guide documents bounded x86-64 atomics, futex wait and wake, and a direct join-only Linux worker slice; general arguments, detach, TLS, cross-platform workers, and a complete memory model remain unsupported.", + "exit_gate": "Public main-loop scheduling, wakeup, cancellation, worker pool, atomics, memory model, thread affinity, shutdown, and race tests pass on every target." + }, + { + "id": "package-resolution", + "category": "package-system", + "state": "partial", + "needed_by": [2, 3, 5, 6, 7], + "evidence": "zag.mod parsing and local dependency validation exist, but the language specification excludes package registry and dependency resolution semantics.", + "exit_gate": "Deterministic resolution, lockfile, checksums, offline mode, path dependencies, conflicts, cache ownership, and reproducible fetch tests pass." + }, + { + "id": "incremental-and-reload-hooks", + "category": "developer-tooling", + "state": "partial", + "needed_by": [2, 3, 5, 6, 7], + "evidence": "The pinned zagd architecture documents a checksummed incremental declaration index and background semantic rechecking, but not an in-process incremental parser or stable state-preserving library reload contract.", + "exit_gate": "Dependency invalidation, state preserving reload, ABI rejection, rollback, file race, crash recovery, and deterministic event hooks pass end to end." + } + ] +} diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 0000000..8b77bac --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,31 @@ +# Architecture map + +Zagkit separates product semantics from rendering transport and platform shell: + +```text +Application views, state, actions, environment + | + reconciliation and layout + / \ + SemanticsNode tree DisplayList + / \ / \ +accessibility automation CPU oracle GPU transport + \ / \ / + public platform input, lifecycle, and surface seams +``` + +The semantics and display trees are parallel outputs of the same retained view +state. Accessibility is not reconstructed from pixels. GPU transports do not +receive view nodes. Platform shells do not own component state. + +Normative decisions: + +- [product and platform](../rfcs/0001-product-and-platform-contract.md) +- [declarative core and rendering](../rfcs/0002-declarative-core-and-rendering.md) +- [text, semantics, and input](../rfcs/0003-text-semantics-and-input.md) +- [platform seams and backend truth](../rfcs/0004-platform-seams-and-backend-truth.md) +- [quality and release](../rfcs/0005-quality-and-release-contract.md) + +The public API names in RFC 0002 are semantic reservations, not compiled API. +The first implementation RFC will fix Zag syntax, ownership, threading, error, +and serialization details after the required upstream primitives are proven. diff --git a/docs/design/visual-direction.md b/docs/design/visual-direction.md new file mode 100644 index 0000000..4e2179b --- /dev/null +++ b/docs/design/visual-direction.md @@ -0,0 +1,41 @@ +# Visual direction selection gate + +- Status: Required, not started +- Blocks: visual component production +- Does not block: architecture, headless primitives, semantics, text engines, + test infrastructure, and benchmark harnesses + +Zagkit needs its own adaptive design language before polished components are +implemented. It should feel precise, calm, fluid, and capable without becoming +an imitation of Apple, Material, Fluent, or a desktop widget theme. + +## Required decision package + +At least three materially different directions must be evaluated using the same +content and interactions. Each direction includes: + +- semantic color, typography, spacing, shape, elevation, material, motion, and + icon token proposals; +- compact, medium, and expanded density behavior; +- desktop pointer and keyboard plus mobile touch examples; +- light, dark, high contrast, RTL, large text, reduced transparency, and reduced + motion variants; +- text field, button, menu, dialog, navigation, list, table, and viewport chrome; +- interrupt, reversal, gesture handoff, focus, error, disabled, selection, and + loading states; +- typography evidence across Latin, Arabic, Hebrew, Indic, CJK, Thai, and emoji. + +## Selection criteria + +The chosen direction must preserve information hierarchy at large text, meet +contrast and target size requirements, expose unmistakable focus, avoid motion +as the only carrier of meaning, remain coherent from CAD density to touch +density, and stay implementable through semantic tokens rather than per-screen +exceptions. + +## Acceptance + +Maintainer and accessibility review accept one direction through an RFC. The +review names known risks and required conformance scenes. Until then, +[the component inventory](../../contracts/components.json) remains `planned` and +no screenshot can promote a component. diff --git a/docs/milestones/0000-product-contract.md b/docs/milestones/0000-product-contract.md new file mode 100644 index 0000000..1734130 --- /dev/null +++ b/docs/milestones/0000-product-contract.md @@ -0,0 +1,41 @@ +# Milestone 0 audit + +- Status: Implemented, pending review and CI +- Date: 2026-08-06 +- Runtime capability promoted: none + +## Deliverables + +| Requirement | Record | Gate | +|---|---|---| +| Separate Apache 2.0 repository | repository root and LICENSE | required file check | +| Governance | GOVERNANCE.md | required file check | +| Semantic versioning and labels | VERSIONING.md, zag.mod | version identity check | +| Support matrix | contracts/platforms.json | platform and capability validation | +| Architecture RFCs | docs/rfcs | accepted RFC check | +| Benchmark scenes | contracts/benchmark-scenes.json | scene and variant validation | +| Component inventory | contracts/components.json | component and visual gate validation | +| Compiler revision | contracts/toolchain.json | exact SHA, version, and edition validation | +| Upstream prerequisites | contracts/upstream-zag.json | required entry and evidence validation | +| Visual direction pause | docs/design/visual-direction.md | blocked gate validation | +| CI contract gate | tools/check-contracts.sh | local and GitHub Actions execution | + +## Zag audit boundary + +The release pin resolves the clean Zag `zag-v2-machine-control` commit +`67cad46feb6a6d912b8d599d7ed3ade7e81175c5`. The neighboring local Zag checkout +contains extensive unrelated changes on another branch. Those changes were not +used to claim capabilities or construct a release identity. + +At the pinned commit, Zag documents x86-64 Linux as supported and ARM64 Linux as +experimental through qemu-user execution. Mach-O, PE/COFF, iOS, Android, the +platform ABIs, general foreign callbacks and aggregates, resource embedding, +cross-platform dynamic loading, complete concurrency, package resolution, and +stable reload hooks remain missing or partial as recorded in the upstream +ledger. + +## Completion meaning + +Passing this milestone means the program can start against a coherent and +machine checked contract. It does not mean the headless core, Linux preview, +text engine, accessibility, components, renderer, or tooling exists. diff --git a/docs/quality/release-gates.md b/docs/quality/release-gates.md new file mode 100644 index 0000000..e68a78d --- /dev/null +++ b/docs/quality/release-gates.md @@ -0,0 +1,82 @@ +# Quality and release gates + +This is the common evidence contract. Milestones select the applicable rows but +cannot weaken their meaning. + +## Headless correctness + +- Unit and property tests for state dependency tracking, bindings, keys, + reconciliation, constraints, intrinsic measurement, geometry, hit testing, + focus, gesture arbitration, and animation clocks. +- Fuzz tests for state event streams, malformed fonts, Unicode, bidi, line + breaking, display lists, semantics, resources, and replay files. +- Differential tests against published Unicode and OpenType data plus internal + slow reference implementations where possible. +- Deterministic replay with state, input, time, platform, backend, loss, and + recovery events. +- Resource ownership tests for creation failure, partial initialization, + cancellation, replacement, device loss, shutdown, and repeated startup. + +## Visual oracle + +CPU goldens cover every axis in +[the benchmark manifest](../../contracts/benchmark-scenes.json): scale factors, +light and dark themes, standard and high contrast, LTR and RTL, normal and large +text, full and reduced motion, and representative locales. + +GPU output is compared to CPU output with versioned tolerance rules by display +list operation, pixel format, color space, and device. An unexplained mismatch +fails. Updating a golden requires a reviewed intent record. + +## Live platform suite + +Each promoted target runs resize, suspend and resume, background and foreground, +surface and device loss, multi window, multi monitor or display movement, +scaling, clipboard, drag and drop, IME composition, missing fonts, keyboard only +operation, focus restoration, shutdown, and crash recovery. + +Native assistive technology runs are mandatory: + +- AT-SPI on Linux; +- VoiceOver on macOS and iOS; +- Narrator on Windows; +- TalkBack on Android. + +Automation through the semantics tree does not replace these runs. + +## Motion and input + +Canonical tests cover interruption, reversal, gesture handoff, capture loss, +reduced motion substitution, resize during transition, refresh rate changes, +coalesced input, and deterministic replay. Position and velocity continuity are +measured where applicable. + +## Performance + +- Active interactions meet the display deadline at p99. +- Scrolling sustains 120 Hz on supported reference hardware. +- Idle scenes perform zero continuous layout and paint work. +- Ten minute canonical runs contain zero unexplained two frame stalls. +- CPU time, GPU time, memory, allocations, cache size, resource counts, input + latency, and frame reasons are captured. +- A regression over 5 percent fails unless a reviewed, expiring waiver exists. + +Reference hardware, thermal state, power mode, OS build, display configuration, +compiler revision, backend, warmup, sample count, and raw results are retained. + +## Packaging and release + +Signed artifacts install, launch, update, and uninstall on every supported OS. +Reproducible builds record exact source and tool inputs plus artifact hashes. +Release notes include capability records, reference hardware, benchmark results, +known unsupported features, and untested hardware. + +## Promotion levels + +| Level | Minimum evidence | +|---|---| +| Experimental | bounded executable slice or specification with explicit unavailable boundaries | +| Alpha | coherent end to end slice with repeatable native tests and known recovery gaps | +| Beta | intended API plus native accessibility, text input, recovery, performance, and packaging evidence | +| Stable | complete common gates, supported version policy, reproducible signed artifacts, and support process | +| 1.0 | Stable gate on all five required platform families plus PrismStudio and mobile reference proof | diff --git a/docs/rfcs/0000-rfc-process.md b/docs/rfcs/0000-rfc-process.md new file mode 100644 index 0000000..4a61bff --- /dev/null +++ b/docs/rfcs/0000-rfc-process.md @@ -0,0 +1,36 @@ +# RFC 0000: RFC process + +- Status: Accepted +- Decision date: 2026-08-06 +- Owners: Zagkit maintainers + +## Purpose + +An RFC records a reviewable decision that changes Zagkit's public API, +architecture, runtime dependency boundary, support truth model, or release +gate. It prevents consequential decisions from being hidden in implementation. + +## Lifecycle + +1. A proposal begins as `Proposed` with context, decision, alternatives, + consequences, compatibility, safety, and verification sections. +2. Maintainers can request evidence or a prototype before accepting it. +3. `Accepted` makes the decision normative. It does not imply implementation. +4. `Implemented` requires links to the executable gates named by the RFC. +5. A later RFC may mark it `Superseded`; rejected proposals remain in history. + +Status changes use dated pull requests. Material scope changes return an RFC to +`Proposed` rather than silently rewriting an accepted decision. + +## Required review + +Dependency and platform seam RFCs require security and ownership review. +Visual system RFCs require accessibility review. Platform promotion and release +gate RFCs require native evidence on the affected targets. + +## Evidence rule + +Prose, code presence, compilation, screenshots, cross execution, and emulation +can support review but do not prove native capability. An implementation claim +names the executable command, exact revision, target device, result artifact, +cleanup behavior, and any unavailable boundary. diff --git a/docs/rfcs/0001-product-and-platform-contract.md b/docs/rfcs/0001-product-and-platform-contract.md new file mode 100644 index 0000000..f92f3cb --- /dev/null +++ b/docs/rfcs/0001-product-and-platform-contract.md @@ -0,0 +1,63 @@ +# RFC 0001: Product and platform contract + +- Status: Accepted +- Decision date: 2026-08-06 +- Owners: Zagkit maintainers + +## Context + +A widget library cannot provide coherent text, gesture, animation, +accessibility, recovery, and tooling guarantees across desktop and mobile. +Proxying platform widgets also fragments behavior and makes renderer truth +uninspectable. + +## Decision + +Zagkit is a separately versioned first party application platform built in Zag. +It owns the view, layout, semantics, text, input, motion, rendering, component, +and tooling layers. It targets Linux, macOS, Windows, iOS, and Android with one +adaptive design system. + +Web, browser rendering, and WebView hosting are outside 1.0. Zagkit is not an +exact imitation of any platform, but adaptive components respect platform input, +navigation, density, menu, typography, and accessibility conventions. + +The parity baseline is the capability developers expect from mature declarative +systems: [SwiftUI accessibility](https://developer.apple.com/documentation/swiftui/accessibility-fundamentals), +[Compose state and semantics](https://developer.android.com/develop/ui/compose/architecture), +[Flutter's layered engine and embedders](https://docs.flutter.dev/resources/architectural-overview), +and the [Qt Quick scene graph](https://doc.qt.io/QT-6/qtquick-visualcanvas-scenegraph.html). +These are architecture references, not runtime dependencies. + +PrismStudio is the first complex consumer after the Linux preview. A focused +mobile reference app proves the mobile architecture. The component gallery is a +conformance tool, not the only product proof. + +## Tooling contract + +The first party command surface is `zagkit init`, `zagkit build`, `zagkit run`, +and `zagkit test`. The development environment includes live preview and reload, +a component gallery, semantic inspector, layout reason inspector, frame +timeline, accessibility overlay, theme editor, snapshot runner, and capability +report. These tools consume the same state, semantics, display list, scheduler, +and capability records as applications. Debug tooling cannot become a second +renderer or hidden source of behavior. + +## Compatibility + +Public API begins unstable under `0.x` SemVer. No platform receives the product +name 1.0 early. The initial stable API freezes only after all five families pass +the shared gate. + +## Consequences + +This is a larger program than wrapping existing native widgets. It creates one +coherent interaction and inspection model, keeps platform seams replaceable, +and forces missing compiler and runtime primitives to improve Zag for every +consumer. + +## Verification + +The repository contract gate must find exactly five required platform families, +an active 1.0 block, an exact compiler pin, and no unsupported capability claim. +Runtime verification is defined in RFC 0005. diff --git a/docs/rfcs/0002-declarative-core-and-rendering.md b/docs/rfcs/0002-declarative-core-and-rendering.md new file mode 100644 index 0000000..31782d6 --- /dev/null +++ b/docs/rfcs/0002-declarative-core-and-rendering.md @@ -0,0 +1,65 @@ +# RFC 0002: Declarative core and rendering architecture + +- Status: Accepted +- Decision date: 2026-08-06 +- Owners: Zagkit maintainers + +## State and authoring contract + +State flows down and actions flow up. The initial public concepts are: + +- `State` owns observable value and revision identity; +- `Binding` provides a scoped read and action based write path; +- `NodeKey` supplies stable identity across reconciliation; +- `Action` is an application intent, not an arbitrary mutation callback; +- `Environment` carries typed inherited values; +- `ViewContext` records state reads, environment reads, actions, and child + identity during view evaluation. + +The exact Zag syntax remains implementation work. These semantic names cannot +be repurposed without a superseding RFC. + +Each state or environment read is tracked. When work is invalidated, the +inspector can name the read identity, old revision, new revision, affected node, +and whether view, measure, layout, paint, or semantics work followed. + +`RenderNode` is the retained escape hatch for specialized layout and painting. +`Canvas` is the immediate drawing escape hatch. Neither bypasses resource +ownership, semantics, damage, capability, or replay contracts. + +## Layout contract + +The core types are `Constraints`, `Size`, and `Rect`. Layout supports intrinsic +measurement, stack, flex, grid, overlay, scroll, virtual list, table, tree, safe +areas, breakpoints, direction, text scale, and platform density. + +Constraints are finite or explicitly unbounded by axis. Measurement and layout +must be deterministic for the same inputs. Cycles, non-finite geometry, +overflow, unstable intrinsic measurement, and duplicate keys fail with +inspectable reasons. + +## Rendering contract + +Rendering produces an immutable `DisplayList` of paths, paints, images, glyph +runs, clips, transforms, layers, and effects. Resources are content addressed +and carry explicit lifetime. Damage and cache reuse cannot change output. + +The CPU renderer is deterministic and is the visual oracle. GPU transports +consume a versioned Zag owned render IR and are compared to the oracle within +documented operation and device tolerances. Metal, D3D12, Vulkan or other +public Linux and Android submission APIs are transports, not the architecture. + +## Scheduling and motion + +The scheduler is refresh rate aware, does no continuous layout or paint while +idle, and records why every frame exists. Springs, keyframes, layout transitions, +and shared transitions are interruptible and deterministic under a supplied +clock. Reduced motion changes behavior through explicit substitution, not a +global duration multiplier. + +## Verification + +Property tests cover constraints and identity. Deterministic replay covers state, +input, time, and backend events. CPU goldens cover the full variant matrix. GPU +comparison, interruption, reversal, resize, idle, and resource cleanup gates +are mandatory before promotion. diff --git a/docs/rfcs/0003-text-semantics-and-input.md b/docs/rfcs/0003-text-semantics-and-input.md new file mode 100644 index 0000000..e60d3da --- /dev/null +++ b/docs/rfcs/0003-text-semantics-and-input.md @@ -0,0 +1,54 @@ +# RFC 0003: Text, semantics, and input ownership + +- Status: Accepted +- Decision date: 2026-08-06 +- Owners: Zagkit maintainers + +## Text decision + +Zagkit owns Unicode decoding and normalization, bidi, segmentation, line +breaking, OpenType shaping, font fallback, variable and color fonts, emoji, +selection, editing, and rasterization. System fonts and published Unicode and +OpenType data are inputs. FreeType, HarfBuzz, Skia, native text widgets, and +browser text engines are not runtime dependencies. + +Malformed input, fonts, tables, clusters, and variation data must fail safely +under fuzzing. Font absence is an observable fallback event, never silent +replacement presented as exact typography. + +## Semantics decision + +Every component produces a parallel `SemanticsNode` tree. Nodes carry stable +identity, role, name, description, value, state, actions, focus order, live +region behavior, selection, ranges, text navigation, bounds, and relationships. + +The semantics tree drives native accessibility adapters, automation, semantic +tests, and the inspector. Accessibility does not scrape pixels or infer meaning +from implementation type names. Visual and semantic trees may differ in shape, +but their relationship is inspectable. + +## Input decision + +The normalized input model covers pointer, keyboard, touch, pen, wheel, +gamepad, focus, commands, drag and drop, and IME composition. Gesture +recognizers participate in explicit arbitration and cancellation. Velocity, +coalesced events, history, capture, and gesture handoff are retained so motion +does not jump when ownership changes. + +Platform text clients remain required seams. AppKit and UIKit adapters expose +their public [accessibility](https://developer.apple.com/documentation/appkit/accessibility-for-appkit) +and [text input](https://developer.apple.com/documentation/appkit/nstextinputclient) +contracts; Windows uses its public +[custom text input](https://learn.microsoft.com/en-us/windows/apps/develop/input/custom-text-input) +and UI Automation contracts; Linux and Android provide equivalent public +adapters. System APIs transport editing and accessibility state but do not own +the document model or semantics tree. + +## Verification + +Unicode and font parsers receive unit, property, fuzz, and differential tests +against published data. Editing suites cover composition in several script +families, selection, replacement, undo, clipboard, missing fonts, and malformed +input. Semantics suites cover role, name, value, actions, focus, live regions, +selection, ranges, and text navigation in automation and native assistive +technologies. diff --git a/docs/rfcs/0004-platform-seams-and-backend-truth.md b/docs/rfcs/0004-platform-seams-and-backend-truth.md new file mode 100644 index 0000000..5077ec8 --- /dev/null +++ b/docs/rfcs/0004-platform-seams-and-backend-truth.md @@ -0,0 +1,54 @@ +# RFC 0004: Platform seams and backend truth + +- Status: Accepted +- Decision date: 2026-08-06 +- Owners: Zagkit maintainers + +## Platform boundary + +Public platform APIs are permitted for lifecycle, windows, surfaces, vsync, GPU +submission, IME, accessibility, clipboard, drag and drop, menus, haptics, +notifications, signing, and packaging. Each adapter is narrow, owned through Zag +types, and independently conformable. Private APIs are prohibited. + +Reusable target, ABI, resource, loading, concurrency, and package features live +in Zag. The prerequisite ledger blocks Zagkit milestones until native Zag +conformance exists. + +## Capability model + +`PlatformCapabilities` is the public immutable snapshot. `BackendEvent` is the +ordered lifecycle record used by diagnostics, replay, tests, and inspectors. + +Every runtime feature reports one of: + +- `supported`, with complete native evidence for the active target; +- `experimental`, with executable evidence plus a named incomplete gate; +- `unavailable`, with a mandatory reason. + +The initial feature set includes shell, CPU renderer, GPU transport, text input, +accessibility, clipboard and drag and drop, multi window, packaging, and backend +selection. Future features use the same vocabulary. + +`.auto` records the actual backend chosen and why higher priority candidates +were rejected. Startup fallback, surface loss, device loss, recovery, and +runtime fallback append timestamped events containing source backend, target +backend, cause, resource impact, and whether visual continuity was preserved. + +No request setting can override unavailable truth. An informed developer may +explicitly select an experimental backend, but the resulting record remains +experimental. + +## GPU boundary + +The CPU renderer remains authoritative. GPU discovery is not submission, +submission is not correct readback, and correct readback is not general renderer +conformance. Physical GPU dispatch is explicit and externally bounded when the +device is display bound or fault isolation is unavailable. + +## Verification + +Capability tests cover priority, explicit selection, unavailable reasons, +startup fallback, mid-frame loss, surface recreation, resource cleanup, event +ordering, and crash recovery. Native platform promotion requires the evidence +bundle defined in RFC 0005. diff --git a/docs/rfcs/0005-quality-and-release-contract.md b/docs/rfcs/0005-quality-and-release-contract.md new file mode 100644 index 0000000..5d70b1e --- /dev/null +++ b/docs/rfcs/0005-quality-and-release-contract.md @@ -0,0 +1,37 @@ +# RFC 0005: Quality and release contract + +- Status: Accepted +- Decision date: 2026-08-06 +- Owners: Zagkit maintainers + +## Decision + +Quality, performance, accessibility, tooling, recovery, and packaging are +release requirements. They are not post-1.0 polish. + +The normative gates are listed in +[release-gates.md](../quality/release-gates.md). A result bundle identifies the +Zagkit and Zag commits, target, device, OS, backend capability record, commands, +durations, artifacts, and cleanup outcome. + +## Performance + +Canonical interactions meet the active display deadline at p99 and preserve +120 Hz scrolling on supported reference hardware. Idle scenes perform no +continuous layout or paint work. Ten minute scripted runs contain no unexplained +two frame stalls. A regression over 5 percent requires a reviewed waiver with +scope, reason, owner, expiry, and recovery plan. + +## Visual correctness + +CPU goldens run across scale, theme, contrast, direction, text scale, reduced +motion, and representative locale axes. GPU output is compared to the CPU oracle +using documented per-operation tolerances. Screenshots alone do not certify +input, semantics, text editing, accessibility, recovery, or performance. + +## Release truth + +Experimental, alpha, and beta releases state their missing gates. 1.0 is +blocked until Linux, macOS, Windows, iOS, and Android pass the common component, +consumer, mobile reference, text, accessibility, recovery, performance, +packaging, and reproducibility requirements. diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md new file mode 100644 index 0000000..e000abb --- /dev/null +++ b/docs/rfcs/README.md @@ -0,0 +1,13 @@ +# RFC index + +| RFC | Title | Status | +|---|---|---| +| [0000](0000-rfc-process.md) | RFC process | Accepted | +| [0001](0001-product-and-platform-contract.md) | Product and platform contract | Accepted | +| [0002](0002-declarative-core-and-rendering.md) | Declarative core and rendering architecture | Accepted | +| [0003](0003-text-semantics-and-input.md) | Text, semantics, and input ownership | Accepted | +| [0004](0004-platform-seams-and-backend-truth.md) | Platform seams and backend truth | Accepted | +| [0005](0005-quality-and-release-contract.md) | Quality and release contract | Accepted | + +Accepted RFCs are decisions, not proof that implementation exists. Current +runtime truth remains in [the platform capability record](../../contracts/platforms.json). diff --git a/tools/check-contracts.sh b/tools/check-contracts.sh new file mode 100755 index 0000000..adb495b --- /dev/null +++ b/tools/check-contracts.sh @@ -0,0 +1,194 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$root" + +fail() { + printf 'contract check: FAIL: %s\n' "$1" >&2 + exit 1 +} + +require_file() { + [ -f "$1" ] || fail "missing required file: $1" +} + +require_json() { + require_file "$1" + jq -e . "$1" >/dev/null || fail "invalid JSON: $1" +} + +command -v jq >/dev/null 2>&1 || fail "jq is required to validate repository contracts" + +for path in \ + LICENSE README.md CHANGELOG.md CONTRIBUTING.md GOVERNANCE.md VERSIONING.md \ + SUPPORT.md SECURITY.md DEPENDENCIES.md ROADMAP.md zag.mod \ + docs/design/visual-direction.md docs/quality/release-gates.md \ + docs/architecture/README.md docs/milestones/0000-product-contract.md \ + benchmarks/README.md; do + require_file "$path" +done + +for path in \ + docs/rfcs/0000-rfc-process.md \ + docs/rfcs/0001-product-and-platform-contract.md \ + docs/rfcs/0002-declarative-core-and-rendering.md \ + docs/rfcs/0003-text-semantics-and-input.md \ + docs/rfcs/0004-platform-seams-and-backend-truth.md \ + docs/rfcs/0005-quality-and-release-contract.md; do + require_file "$path" + grep -q 'Status: Accepted' "$path" || fail "$path is not accepted" +done + +for path in contracts/toolchain.json contracts/platforms.json \ + contracts/upstream-zag.json contracts/components.json \ + contracts/benchmark-scenes.json; do + require_json "$path" +done + +manifest_version=$(awk -F '"' '/^[[:space:]]*version[[:space:]]*=/{print $2; exit}' zag.mod) +manifest_edition=$(awk -F '"' '/^[[:space:]]*edition[[:space:]]*=/{print $2; exit}' zag.mod) +contract_version=$(jq -r '.zagkit_version' contracts/toolchain.json) +platform_version=$(jq -r '.generated_for' contracts/platforms.json) +component_version=$(jq -r '.generated_for' contracts/components.json) +benchmark_version=$(jq -r '.generated_for' contracts/benchmark-scenes.json) + +[ -n "$manifest_version" ] || fail "zag.mod has no version" +printf '%s\n' "$manifest_version" | grep -Eq '^0\.[0-9]+\.[0-9]+-(experimental|alpha|beta)\.[0-9]+$' \ + || fail "pre-1.0 version must carry an experimental, alpha, or beta prerelease" +[ "$contract_version" = "$manifest_version" ] || fail "toolchain version does not match zag.mod" +[ "$platform_version" = "$manifest_version" ] || fail "platform version does not match zag.mod" +[ "$component_version" = "$manifest_version" ] || fail "component version does not match zag.mod" +[ "$benchmark_version" = "$manifest_version" ] || fail "benchmark version does not match zag.mod" +[ "$manifest_edition" = "$(jq -r '.zag.edition' contracts/toolchain.json)" ] \ + || fail "Zag edition does not match zag.mod" + +compiler_commit=$(jq -r '.zag.commit' contracts/toolchain.json) +printf '%s\n' "$compiler_commit" | grep -Eq '^[0-9a-f]{40}$' \ + || fail "toolchain commit is not an exact lowercase Git SHA" +jq -e '.zag.repository == "https://github.com/Sylorlabs/zag" and + (.zag.resolved_ref | startswith("refs/heads/")) and + (.zag.compiler_version | length > 0) and + (.audited_on | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) and + (.evidence | length >= 3)' contracts/toolchain.json >/dev/null \ + || fail "toolchain record is incomplete" + +expected_platforms='android ios linux macos windows' +actual_platforms=$(jq -r '.platforms[].id' contracts/platforms.json | sort | tr '\n' ' ' | sed 's/ $//') +required_platforms=$(jq -r '.one_point_zero.required_platforms[]' contracts/platforms.json | sort | tr '\n' ' ' | sed 's/ $//') +[ "$actual_platforms" = "$expected_platforms" ] || fail "platform matrix must contain exactly the five required families" +[ "$required_platforms" = "$expected_platforms" ] || fail "1.0 gate must require exactly the five platform families" + +jq -e ' + .status_vocabulary == ["supported", "experimental", "unavailable"] and + (.required_capabilities | sort) as $required | + all(.platforms[]; + (.public_channel | IN("experimental", "alpha", "beta", "stable")) and + ((.capabilities | map(.id) | sort) == $required) and + ((.capabilities | map(.id) | unique | length) == ($required | length)) and + all(.capabilities[]; + (.state | IN("supported", "experimental", "unavailable")) and + (.reason | type == "string" and length > 0) and + (.evidence | type == "array") and + (if .state == "unavailable" then true else (.evidence | length > 0) end) + ) + ) and + (.one_point_zero.blocked == true) and + all(.platforms[].capabilities[]; .state == "unavailable") +' contracts/platforms.json >/dev/null || fail "platform capability truth is incomplete or inflated" + +ledger_commit=$(jq -r '.audited_commit' contracts/upstream-zag.json) +[ "$ledger_commit" = "$compiler_commit" ] || fail "upstream ledger and toolchain pin audit different Zag commits" +jq -e ' + (.entries | length >= 15) and + ((.entries | map(.id) | unique | length) == (.entries | length)) and + all(.entries[]; + (.state | IN("available", "partial", "missing")) and + (.category | type == "string" and length > 0) and + (.needed_by | type == "array" and length > 0) and + all(.needed_by[]; . >= 2 and . <= 7) and + (.evidence | type == "string" and length > 20) and + (.exit_gate | type == "string" and length > 20) + ) +' contracts/upstream-zag.json >/dev/null || fail "upstream prerequisite ledger is incomplete" + +for id in \ + target-linux-arm64 target-darwin-macho target-windows-pe-coff \ + target-ios-arm64 target-android-arm64 abi-objective-c abi-com abi-jni \ + abi-callbacks abi-aggregates resource-embedding dynamic-platform-loading \ + main-loop-and-workers package-resolution incremental-and-reload-hooks; do + jq -e --arg id "$id" 'any(.entries[]; .id == $id)' contracts/upstream-zag.json >/dev/null \ + || fail "upstream prerequisite is missing: $id" +done + +jq -e ' + (.visual_direction_gate.state == "blocked") and + (.components | length >= 40) and + ((.components | map(.id) | unique | length) == (.components | length)) and + all(.components[]; + (.status == "planned") and + (.milestone >= 2 and .milestone <= 4) and + (.semantic_roles | type == "array" and length > 0) and + (.inputs | type == "array") and + (.adaptive | type == "array" and length > 0) + ) +' contracts/components.json >/dev/null || fail "component inventory or visual production gate is incomplete" + +jq -e ' + (.result_state == "specifications-only") and + (.scenes | length >= 10) and + ((.scenes | map(.id) | unique | length) == (.scenes | length)) and + all(.scenes[]; + (.status == "specification") and + (.milestone >= 2 and .milestone <= 4) and + (.canonical_interaction | type == "string" and length > 20) and + (.stresses | length >= 3) and + (.assertions | length >= 4) + ) and + (.variant_matrix.scale_factors | length >= 5) and + (.variant_matrix.themes == ["light", "dark"]) and + (.variant_matrix.contrast | length == 2) and + (.variant_matrix.directions | sort == ["ltr", "rtl"]) and + (.variant_matrix.text_scales | length >= 3) and + (.variant_matrix.motion | length == 2) and + (.variant_matrix.locales | length >= 7) and + (.global_performance_contract.frame_deadline_p99_fraction_max == 1.0) and + (.global_performance_contract.scroll_refresh_hz_on_supported_reference_hardware == 120) and + (.global_performance_contract.idle_layouts_per_second_max == 0) and + (.global_performance_contract.idle_paints_per_second_max == 0) and + (.global_performance_contract.unexplained_two_frame_stalls_per_ten_minutes_max == 0) and + (.global_performance_contract.regression_without_reviewed_waiver_percent_max == 5.0) +' contracts/benchmark-scenes.json >/dev/null || fail "benchmark scene contract is incomplete" + +if [ -d ../zag/.git ]; then + git -C ../zag cat-file -e "$compiler_commit^{commit}" 2>/dev/null \ + || fail "neighboring Zag repository does not contain the pinned commit" + printf 'contract check: verified pinned Zag commit in neighboring checkout\n' +else + printf 'contract check: neighboring Zag checkout absent; exact SHA format validated only\n' +fi + +for source_root in src packages platform; do + if [ -d "$source_root" ] && find "$source_root" -type f \ + \( -name '*.c' -o -name '*.cc' -o -name '*.cpp' -o -name '*.cxx' \ + -o -name '*.zig' -o -name '*.rs' \) -print -quit | grep -q .; then + fail "foreign implementation source found under $source_root" + fi +done + +find . -path './.git' -prune -o -name '*.md' -type f -print | sort | +while IFS= read -r markdown_file; do + markdown_base=$(dirname "$markdown_file") + grep -oE '\]\([^)]+\)' "$markdown_file" | + sed -e 's/^](//' -e 's/)$//' | + while IFS= read -r target; do + case "$target" in + ''|'#'*|http://*|https://*|mailto:*) continue ;; + esac + link_path=${target%%#*} + [ -e "$markdown_base/$link_path" ] \ + || fail "broken Markdown link in $markdown_file: $target" + done +done + +printf 'contract check: PASS (%s, Zag %s)\n' "$manifest_version" "$compiler_commit" diff --git a/zag.mod b/zag.mod new file mode 100644 index 0000000..fc46da1 --- /dev/null +++ b/zag.mod @@ -0,0 +1,3 @@ +name = "zagkit" +version = "0.1.0-experimental.0" +edition = "2026" From 2b159f65fdebfb1e7656f9907880eeae82781478 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 02:09:51 -0700 Subject: [PATCH 002/103] Bind expanded Zagkit goal to CI checklist --- CONTRIBUTING.md | 10 +- GOAL.md | 139 ++++++++++++++++++ README.md | 7 +- ROADMAP.md | 26 +++- contracts/benchmark-scenes.json | 36 +++++ contracts/components.json | 6 + docs/design/visual-direction.md | 18 ++- docs/milestones/0000-product-contract.md | 2 + docs/quality/release-gates.md | 23 +++ ...alkback-visual-fidelity-and-prismstudio.md | 103 +++++++++++++ docs/rfcs/README.md | 1 + tools/check-contracts.sh | 23 ++- 12 files changed, 381 insertions(+), 13 deletions(-) create mode 100644 GOAL.md create mode 100644 docs/rfcs/0006-flex-talkback-visual-fidelity-and-prismstudio.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0bd23c5..20a10f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,9 +9,11 @@ architecture and support claims in this repository. the [accepted RFCs](docs/rfcs/README.md). 2. Check [the upstream ledger](contracts/upstream-zag.json). A reusable compiler, ABI, concurrency, package, or platform defect belongs in Zag first. -3. Check the visual direction gate. Visual component production cannot begin +3. Read the [active master goal checklist](GOAL.md) and name the stable IDs the + change advances. Never remove or check unrelated items. +4. Check the visual direction gate. Visual component production cannot begin until [the design review](docs/design/visual-direction.md) is accepted. -4. Add or update the smallest contract, test, benchmark, or conformance scene +5. Add or update the smallest contract, test, benchmark, or conformance scene that can prove the change. ## Change requirements @@ -29,6 +31,10 @@ architecture and support claims in this repository. - Tests must include cleanup and negative behavior where resources, malformed input, callbacks, device loss, or platform boundaries are involved. - Keep unrelated worktree changes out of the commit. +- Give every actionable component and application node a stable Zagkit Talkback + ID; pixel fallback must be explicit, observable, and justified. +- Use Flex semantic spacing and placement rules. Unexplained screen-local + offsets are defects, not polish. An upstream fix is complete only when it is committed separately in `/home/micah/Desktop/Sylorlabs/zag`, carries source and native executable diff --git a/GOAL.md b/GOAL.md new file mode 100644 index 0000000..1d92286 --- /dev/null +++ b/GOAL.md @@ -0,0 +1,139 @@ +# Zagkit master goal checklist + +- Goal status: **Active** +- Completion meaning: every item below is checked and carries reviewable evidence +- Scope: Zagkit, upstream Zag prerequisites, and the Zagkit based PrismStudio overhaul +- Rule: an implementation, screenshot, benchmark, or platform claim never checks a box by itself; the named exit condition must pass + +This is the durable execution checklist for humans and agents. Stable IDs may be +referenced by issues, commits, result bundles, and release notes. Do not delete, +weaken, or check an item to make a milestone appear complete. Split an item when +more precision is needed and preserve its original ID as the parent. + +## 0. Repository and product contract + +- [x] `G0-REPOSITORY` Establish the separate Apache-2.0 Zagkit repository. — Evidence: [LICENSE](LICENSE), [governance](GOVERNANCE.md) +- [x] `G0-VERSIONING` Establish semantic versioning and honest preview labels. — Evidence: [VERSIONING.md](VERSIONING.md), [zag.mod](zag.mod) +- [x] `G0-PLATFORMS` Record all five required platform families and fail-closed capability truth. — Evidence: [platform contract](contracts/platforms.json) +- [x] `G0-ARCHITECTURE` Accept the initial declarative, rendering, text, semantics, platform, and quality RFCs. — Evidence: [RFC index](docs/rfcs/README.md) +- [x] `G0-DEPENDENCIES` Forbid foreign UI and rendering engines from Zagkit core. — Evidence: [dependency boundary](DEPENDENCIES.md) +- [x] `G0-TOOLCHAIN` Pin each Zagkit release to an exact clean Zag revision. — Evidence: [toolchain contract](contracts/toolchain.json) +- [x] `G0-UPSTREAM` Record missing Zag prerequisites and native exit gates. — Evidence: [upstream ledger](contracts/upstream-zag.json) +- [x] `G0-BENCHMARKS` Define canonical benchmark scenes and variant axes without claiming results. — Evidence: [benchmark contract](contracts/benchmark-scenes.json) +- [x] `G0-COMPONENTS` Inventory the initial shared component surface. — Evidence: [component contract](contracts/components.json) +- [x] `G0-CI` Validate the product contracts in CI. — Evidence: [contract workflow](.github/workflows/contracts.yml) +- [x] `G0-EXPANDED-SCOPE` Bind Flex, Zagkit Talkback, visual fidelity, and PrismStudio redesign to release gates. — Evidence: [RFC 0006](docs/rfcs/0006-flex-talkback-visual-fidelity-and-prismstudio.md) +- [ ] `G0-VISUAL-DIRECTION` Select one of three accessibility-reviewed visual directions. — Exit: accepted visual-direction RFC with comparison images, risks, and conformance scenes + +## 1. Advance Zag at the source + +- [ ] `G1-LINUX-ARM64` Complete and natively conform the Linux ARM64 compiler target. — Exit: pure Zag ABI, executable, cleanup, and physical-device suites pass +- [ ] `G1-DARWIN` Implement Mach-O x86-64 and ARM64 targets. — Exit: native macOS executables pass ABI and lifecycle conformance +- [ ] `G1-WINDOWS` Implement PE/COFF x86-64 and ARM64 targets. — Exit: native Windows executables pass ABI, unwind, resource, and lifecycle conformance +- [ ] `G1-IOS` Implement the iOS ARM64 target. — Exit: signed Zag code runs on physical supported iOS devices +- [ ] `G1-ANDROID` Implement the Android ARM64 target. — Exit: Zag output runs on physical supported Android devices +- [ ] `G1-OBJC` Implement typed Objective-C runtime ABI seams. — Exit: message, callback, aggregate, ownership, and failure suites pass on macOS and iOS +- [ ] `G1-COM` Implement typed COM ABI seams. — Exit: lifetime, interface, callback, HRESULT, threading, and aggregate suites pass on Windows +- [ ] `G1-JNI` Implement typed JNI ABI seams. — Exit: calls, references, exceptions, threads, callbacks, and cleanup pass on Android +- [ ] `G1-CALLBACKS` Complete general foreign callback ABI support. — Exit: register, stack, lifetime, reentrancy, and negative suites pass per target +- [ ] `G1-AGGREGATES` Complete foreign aggregate argument and return support. — Exit: structs, unions, vectors, floats, alignment, and register-class suites pass per target +- [ ] `G1-RESOURCES` Implement deterministic compiler-owned resource embedding. — Exit: binary, empty, relative, malformed, cache-identity, reproducibility, and target suites pass +- [ ] `G1-DYNAMIC-LOAD` Complete typed cross-platform dynamic loading. — Exit: lookup, version failure, ownership, unload, callback, and aggregate suites pass +- [ ] `G1-CONCURRENCY` Complete main-loop and worker primitives with a documented memory model. — Exit: wakeup, cancellation, affinity, race, shutdown, and platform suites pass +- [ ] `G1-PACKAGES` Complete deterministic package resolution. — Exit: locks, checksums, offline, path, conflict, cache, and reproducibility suites pass +- [ ] `G1-RELOAD` Complete incremental compilation and safe reload hooks. — Exit: invalidation, state preservation, rollback, file-race, and crash-recovery suites pass +- [ ] `G1-SOURCE-FIRST` Keep reusable failures out of downstream workarounds and fallback languages. — Exit: every downstream Zag defect links to an upstream Zag regression and exact fixed revision + +## 2. Declarative and headless core + +- [ ] `G2-STATE` Implement `State`, `Binding`, actions, environment, and exact dependency reads. — Exit: unit, property, and deterministic replay suites pass +- [ ] `G2-RECONCILE` Implement keyed reconciliation and retained `RenderNode` ownership. — Exit: identity, reorder, replacement, cancellation, and cleanup suites pass +- [ ] `G2-CONSTRAINTS` Implement constraints, intrinsic measurement, size, rect, and invalidation reasons. — Exit: property tests prove bounded geometry and exact recomputation causes +- [ ] `G2-FLEX` Implement the Flex placement and spacing system. — Exit: tokenized gap, padding, alignment, distribution, baseline, wrap, grid, overlay, and breakpoint suites pass +- [ ] `G2-FLEX-RTL` Make Flex correct for RTL, safe areas, text scale, density, and platform adaptation. — Exit: full layout matrix passes without per-screen spacing exceptions +- [ ] `G2-VIRTUALIZATION` Implement scroll, virtual list, table, tree, and grid. — Exit: million-row identity, semantic range, bounded-node, and 120 Hz reference suites pass +- [ ] `G2-DISPLAY-LIST` Implement immutable paths, paints, images, glyphs, clips, transforms, layers, and effects. — Exit: fuzz, serialization, ownership, and replay suites pass +- [ ] `G2-CPU-RASTER` Implement the deterministic CPU visual oracle. — Exit: cross-machine goldens and malformed-input suites pass at every declared scale +- [ ] `G2-HIT-TEST` Implement transformed hit testing, capture, focus, and event routing. — Exit: property, overlap, clipping, cancellation, and replay suites pass +- [ ] `G2-SEMANTICS` Implement the parallel semantics tree. — Exit: roles, names, values, actions, focus, live regions, ranges, selection, and text navigation pass +- [ ] `G2-REPLAY` Implement deterministic state, input, time, backend, loss, and recovery replay. — Exit: identical inputs produce identical semantic, layout, display-list, and CPU outputs + +## 3. Text, assets, materials, and motion + +- [ ] `G3-UNICODE` Implement Zag-owned decoding, normalization, bidi, segmentation, and line breaking. — Exit: published-data differential, fuzz, malformed, and locale suites pass +- [ ] `G3-OPENTYPE` Implement Zag-owned OpenType shaping and rasterization. — Exit: fallback, variable, color, emoji, malformed-font, and reference suites pass +- [ ] `G3-EDITING` Implement selection, caret, undo, composition, and text navigation. — Exit: multilingual IME and editing replay suites pass +- [ ] `G3-FONTS` Implement precise typography tokens, fallback policy, hinting, and density-independent rasterization. — Exit: baseline, legibility, large-text, and international type-ramp goldens pass +- [ ] `G3-COLOR` Implement semantic color, wide-gamut conversion, alpha, contrast, and color-management contracts. — Exit: light, dark, high-contrast, and color-space oracle suites pass +- [ ] `G3-SVG` Implement secure scalable SVG decoding and rendering without a foreign UI engine. — Exit: path, gradient, transform, clip, text-policy, malformed, and scale goldens pass +- [ ] `G3-PNG` Implement color-managed PNG decoding and rendering. — Exit: alpha, palette, grayscale, color-profile, malformed, bomb-limit, and scale suites pass +- [ ] `G3-SHADOWS` Implement soft multi-lobe shadows and elevation tokens without pixelated edges. — Exit: CPU goldens and GPU tolerance suites pass across scales and contrast modes +- [ ] `G3-LIGHTING` Implement coherent light, surface, highlight, and depth composition. — Exit: deterministic material scenes preserve hierarchy in light, dark, and high contrast +- [ ] `G3-GLASS` Implement adaptive liquid-glass materials with blur, tint, refraction, highlights, and depth. — Exit: motion, overlap, text-legibility, reduced-transparency, CPU-oracle, and GPU suites pass +- [ ] `G3-MOTION` Implement springs, keyframes, layout, shared, and gesture-driven transitions. — Exit: interruption, reversal, velocity continuity, resize, and deterministic replay pass +- [ ] `G3-REDUCED-MOTION` Implement semantic reduced-motion substitutions. — Exit: every canonical animation has a tested non-motion or reduced-motion equivalent +- [ ] `G3-ASSET-PIPELINE` Implement resource identity, decoding limits, caching, invalidation, and cleanup. — Exit: malformed, replacement, missing, memory-pressure, and repeat-start suites pass + +## 4. Input, accessibility, Talkback, and tooling + +- [ ] `G4-INPUT` Implement pointer, keyboard, touch, pen, wheel, gamepad, and command routing. — Exit: arbitration, capture-loss, coalescing, focus, and replay suites pass +- [ ] `G4-GESTURES` Implement gesture arbitration, velocity, drag and drop, and handoff. — Exit: nested recognizer continuity and cleanup suites pass +- [ ] `G4-TALKBACK-PROTOCOL` Specify the versioned Zagkit Talkback native automation protocol. — Exit: request, response, event, timeout, capability, and error schemas are accepted +- [ ] `G4-TALKBACK-IDS` Expose stable developer-assigned and deterministic generated node IDs. — Exit: duplicate, stale, virtualized, window, reload, and semantic-tree identity suites pass +- [ ] `G4-TALKBACK-ACTIONS` Support semantic query, click, type, scroll, drag, focus, wait, assert, snapshot, and replay actions. — Exit: agent scripts exercise every action without pixel coordinates +- [ ] `G4-TALKBACK-PIXELS` Provide an explicit pixel-coordinate fallback. — Exit: fallback is capability-gated, scale-aware, logged, replayable, and never misreported as ID targeting +- [ ] `G4-TALKBACK-INSPECT` Ship tree, layout-reason, screenshot, timeline, and capability inspection. — Exit: an agent can diagnose a failed action from one retained evidence bundle +- [ ] `G4-TALKBACK-NAME` Keep Zagkit Talkback distinct from Android TalkBack accessibility. — Exit: public APIs, CLI help, docs, and reports use unambiguous qualified names +- [ ] `G4-ACCESSIBILITY` Implement Linux AT-SPI, Apple accessibility, Windows UIA, and Android accessibility adapters. — Exit: native assistive-technology suites pass on every promoted platform +- [ ] `G4-CLI` Implement `zagkit init`, `build`, `run`, and `test`. — Exit: reproducible clean-project workflows pass on every promoted target +- [ ] `G4-PREVIEW` Implement live preview and state-safe reload. — Exit: compatible state survives, incompatible changes roll back clearly, and crashes recover +- [ ] `G4-INSPECTORS` Implement semantic, accessibility, layout-reason, frame, theme, and capability inspectors. — Exit: inspectors agree with recorded runtime truth and add no idle work when closed +- [ ] `G4-GALLERY` Implement the component gallery as a conformance surface. — Exit: every conformant component exposes states, variants, semantics, motion, and goldens + +## 5. Polished Linux reference platform + +- [ ] `G5-WAYLAND` Implement the Wayland shell first. — Exit: lifecycle, windows, surfaces, scaling, input, clipboard, IME, and recovery pass on target distributions +- [ ] `G5-X11` Implement the X11 fallback without claiming Wayland equivalence. — Exit: declared X11 capability and limitation suites pass +- [ ] `G5-ATSPI` Implement and verify native AT-SPI exposure. — Exit: keyboard-only and supported Linux screen-reader scripts pass +- [ ] `G5-LINUX-CPU` Implement CPU presentation as the always-available visual oracle path. — Exit: live resize, multi-monitor, surface-loss, and ten-minute cleanup suites pass +- [ ] `G5-LINUX-GPU` Implement one explicit public Linux GPU transport. — Exit: opt-in physical-device execution, loss recovery, CPU comparison, and cleanup pass +- [ ] `G5-LINUX-POLISH` Make Linux the first no-rough-edges reference experience. — Exit: no known severity-one or severity-two visual, input, text, accessibility, recovery, or packaging defects remain +- [ ] `G5-LINUX-FIDELITY` Eliminate density-dependent and pixelated UI output. — Exit: screenshot comparisons pass at 1.0, 1.25, 1.5, 2.0, and 3.0 scales with crisp type, SVG, PNG, curves, glass, and shadows +- [ ] `G5-LINUX-PACKAGE` Package, install, launch, update, and uninstall Linux artifacts. — Exit: Ubuntu LTS and Fedora x86-64 and ARM64 release matrices pass where target support is declared + +## 6. Complete PrismStudio overhaul + +- [ ] `G6-INVENTORY` Inventory every existing PrismStudio workflow, command, screen, and safety boundary. — Exit: migration map has no unowned visible or keyboard-accessible behavior +- [ ] `G6-DESIGN` Select and approve one of three PrismStudio visual directions using representative CAD states. — Exit: maintainers choose a reviewed target with light, dark, contrast, large-text, and reduced-effects variants +- [ ] `G6-SHELL` Replace the entire visible PrismStudio shell with Zagkit. — Exit: no legacy app-owned widget, layout, or styling path remains in the supported Linux product +- [ ] `G6-WORKFLOWS` Preserve and modernize all CAD workflows and direct manipulation. — Exit: canonical task scripts pass by keyboard, pointer, and Zagkit Talkback IDs +- [ ] `G6-VIEWPORT` Migrate viewport chrome, overlays, tools, and input while retaining the explicit GPU safety boundary. — Exit: CPU-safe suite and separately authorized GPU evidence pass without weakening safety +- [ ] `G6-DENSE-UI` Migrate tables, trees, properties, menus, toolbars, dialogs, and command palette. — Exit: density, focus, virtualization, editing, semantics, and responsive-layout suites pass +- [ ] `G6-MATERIALS` Apply production typography, color, lighting, shadows, and liquid-glass materials coherently. — Exit: approved comparison screenshots pass at representative scales and states +- [ ] `G6-ASSETS` Migrate icons and imagery to crisp SVG, PNG, and font-backed assets. — Exit: no placeholder, ASCII, emoji, pixel-stretched, or missing production asset remains +- [ ] `G6-AUTOMATION` Give every actionable PrismStudio node a stable Talkback ID. — Exit: the canonical regression suite uses IDs for all normal actions and records every pixel fallback +- [ ] `G6-ACCESSIBILITY` Make the complete redesigned UI semantic and keyboard operable. — Exit: AT-SPI, focus, large-text, high-contrast, and keyboard-only suites pass +- [ ] `G6-SCREENSHOTS` Verify the actual native app with repeatable reference screenshots. — Exit: matched-state comparisons pass for startup, editing, menus, dialogs, viewport, long sessions, and recovery +- [ ] `G6-PERFORMANCE` Meet frame, idle, input-latency, memory, and stall budgets in real CAD scenes. — Exit: ten-minute reference traces pass with no unexplained two-frame stalls +- [ ] `G6-POLISH` Resolve all known rough edges in the supported Linux PrismStudio experience. — Exit: reviewed bug inventory has no open severity-one or severity-two product-quality issue + +## 7. Desktop, mobile, and shared 1.0 + +- [ ] `G7-MACOS` Ship the adaptive macOS shell, Metal transport, IME, VoiceOver, menus, drag and drop, and signed packaging. — Exit: macOS beta and stable gates pass on supported versions +- [ ] `G7-WINDOWS` Ship the adaptive Windows shell, D3D12 transport, Core Text input, Narrator, menus, drag and drop, and signed packaging. — Exit: Windows beta and stable gates pass on supported versions +- [ ] `G7-IOS` Ship the touch-first iOS shell, Metal transport, lifecycle restoration, IME, VoiceOver, haptics, and signed packaging. — Exit: iOS beta and stable gates pass on physical supported devices +- [ ] `G7-ANDROID` Ship the touch-first Android shell, GPU transport, lifecycle restoration, IME, Android TalkBack, haptics, and signed packaging. — Exit: Android beta and stable gates pass on physical supported devices +- [ ] `G7-MOBILE-REFERENCE` Ship and verify a focused mobile reference application. — Exit: shared components, navigation, text, semantics, recovery, performance, and packaging pass on iOS and Android +- [ ] `G7-COMPONENT-PARITY` Complete the shared adaptive component suite on all five families. — Exit: every inventory row is conformant with platform adaptation evidence +- [ ] `G7-TEXT-PARITY` Pass international text, IME, editing, fonts, and asset fidelity on all five families. — Exit: common text and visual matrices pass without hidden system-engine substitution +- [ ] `G7-RECOVERY` Pass device, surface, process, suspend, resume, loss, and cleanup gates on all five families. — Exit: live platform result bundles contain no unexplained leak or unrecovered loss +- [ ] `G7-PERFORMANCE` Pass common p99, 120 Hz reference, idle, memory, and stall gates. — Exit: reviewed result bundles pass on declared reference hardware +- [ ] `G7-PACKAGING` Install, launch, update, and uninstall signed artifacts on every supported OS. — Exit: reproducible packaging matrices and artifact hashes pass +- [ ] `G7-ONE-POINT-ZERO` Release Zagkit 1.0 only as one shared five-platform product. — Exit: every checklist item is checked, capability truth is supported, and no required waiver is expired + +## Checklist maintenance + +`./tools/check-contracts.sh` verifies that this file remains active, keeps stable +unique IDs, retains the non-negotiable requirements, and gives every checked +item evidence and every unchecked item an exit condition. That check validates +the plan's integrity; it does not certify the unfinished work. diff --git a/README.md b/README.md index 15daa30..5fdffc9 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ being designed for one adaptive product codebase across Linux, macOS, Windows, iOS, and Android, with precise text, fluid interaction, built in semantics, and inspectable runtime truth. +The durable, CI-checked execution plan is the [master goal checklist](GOAL.md). +It includes the Flex placement system, Zagkit Talkback native automation, +modern materials and asset fidelity, and a complete PrismStudio UI replacement. + This repository is at **0.1.0-experimental.0**. It currently contains the accepted product contract and executable Milestone 0 checks. It does not yet contain a usable renderer, window shell, component library, or supported @@ -37,6 +41,7 @@ is normative in [DEPENDENCIES.md](DEPENDENCIES.md). | Platform shells | unavailable | [support matrix](SUPPORT.md) | | Headless core | not implemented | [roadmap](ROADMAP.md) | | Components and visual language | inventory only, visual review pending | [component inventory](contracts/components.json) | +| Flex and Zagkit Talkback | contract accepted, not implemented | [RFC 0006](docs/rfcs/0006-flex-talkback-visual-fidelity-and-prismstudio.md) | | Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | Run the repository contract gate with: @@ -54,7 +59,7 @@ inventory, benchmark scene coverage, and the 1.0 block. 1. Advance reusable compiler, ABI, concurrency, package, and platform features in [Zag](https://github.com/Sylorlabs/zag), each with native executable proof. 2. Build Zagkit's deterministic headless core. -3. Ship an honest Linux preview, then prove the architecture in PrismStudio. +3. Ship a polished Linux reference, then completely rebuild PrismStudio's UI on Zagkit. 4. Reach desktop parity on macOS and Windows. 5. Reach mobile parity on iOS and Android. 6. Call the shared product 1.0 only after all five families pass the same gate. diff --git a/ROADMAP.md b/ROADMAP.md index 3d20a50..5971c91 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,6 +16,8 @@ to executable evidence. inventory, compiler pin, and upstream Zag prerequisite ledger. - Executable contract validation in CI. - Explicit visual direction and accessibility review gate. +- CI-checked master goal checklist covering Flex, Zagkit Talkback, visual and + asset fidelity, and the complete PrismStudio overhaul. This milestone creates no runtime support. Its completion cannot promote any platform capability. @@ -42,6 +44,12 @@ animation clocks, input routing, hit testing, snapshots, and deterministic replay without a window system. Exit requires unit, property, fuzz, differential, golden, and replay gates. +Flex is the common placement and spacing system. Zagkit Talkback is built over +retained node and semantic identity with ID-first interaction and observable, +scale-aware pixel fallback. Fonts, semantic colors, SVG, PNG, lighting, soft +shadows, and adaptive liquid-glass materials are renderer and asset-pipeline +requirements, including designed reduced-effects variants. + ## 3. Linux preview **Status: not started.** @@ -52,15 +60,23 @@ public GPU transport. Exit requires live native execution and fail closed capability reporting on Ubuntu LTS and current Fedora baselines for x86-64 and ARM64 where the compiler target exists. +Linux is the first polish reference: modern density-independent output, exact +spacing, crisp curves and assets, coherent motion and glass, native +accessibility, Talkback automation, recovery, screenshots at all declared +scales, and packaging must have no known severity-one or severity-two defects. + ## 4. PrismStudio dogfood **Status: not started.** -Migrate PrismStudio as the first complex consumer. Preserve CAD workflows, -direct manipulation, tables, menus, keyboard control, viewport behavior, -performance evidence, and its explicit physical GPU safety boundary. The -component gallery remains a conformance surface; PrismStudio is the product -proof. +Replace the complete visible PrismStudio UI with Zagkit as the first complex +consumer. Preserve and modernize every CAD workflow, direct manipulation path, +table, tree, menu, keyboard command, dialog, viewport behavior, performance +contract, and explicit physical GPU safety boundary. Apply the selected Zagkit +direction, Flex spacing, production fonts and colors, SVG and PNG assets, +lighting, soft shadows, motion, and liquid-glass materials. Every actionable +node receives a stable Talkback ID. Native screenshot comparisons are required +alongside semantics, AT-SPI, interaction, recovery, and performance evidence. ## 5. Desktop parity diff --git a/contracts/benchmark-scenes.json b/contracts/benchmark-scenes.json index 0e69aaf..0988ddd 100644 --- a/contracts/benchmark-scenes.json +++ b/contracts/benchmark-scenes.json @@ -111,6 +111,42 @@ "canonical_interaction": "Animate clipped transformed layers with paths, glyphs, images, opacity, blur, and shadows.", "stresses": ["display-list", "clipping", "transforms", "layers", "effects", "rasterization"], "assertions": ["deterministic-golden", "cpu-oracle", "gpu-tolerance", "bounded-resources", "damage-correctness"] + }, + { + "id": "material-fidelity", + "name": "Lighting, shadow, and liquid-glass fidelity", + "milestone": 2, + "status": "specification", + "canonical_interaction": "Move, overlap, resize, interrupt, and scroll translucent materials across changing light and content.", + "stresses": ["backdrop", "blur", "refraction", "lighting", "shadows", "color-management", "animation"], + "assertions": ["crisp-all-scales", "text-legibility", "reduced-transparency-substitution", "cpu-oracle", "gpu-tolerance", "no-two-frame-stall"] + }, + { + "id": "asset-fidelity", + "name": "Font, SVG, and PNG fidelity", + "milestone": 2, + "status": "specification", + "canonical_interaction": "Zoom and rescale international text, SVG icons, and color-managed PNG images through every declared density.", + "stresses": ["font-rasterization", "svg", "png", "color-space", "alpha", "scaling", "malformed-assets"], + "assertions": ["no-pixelation", "color-profile-correctness", "bounded-decoding", "deterministic-golden", "semantic-labels"] + }, + { + "id": "talkback-native-automation", + "name": "Zagkit Talkback native automation", + "milestone": 2, + "status": "specification", + "canonical_interaction": "Discover, query, click, type, scroll, drag, wait, assert, capture, and replay a multi-window app by stable node ID.", + "stresses": ["stable-ids", "semantics", "virtualization", "reload", "multi-window", "timeouts", "pixel-fallback"], + "assertions": ["id-first-actions", "structured-failures", "fallback-observability", "deterministic-replay", "capability-truth"] + }, + { + "id": "prismstudio-complete-ui", + "name": "Complete PrismStudio Zagkit experience", + "milestone": 4, + "status": "specification", + "canonical_interaction": "Complete representative CAD editing, inspection, menu, dialog, navigation, and recovery workflows in the fully redesigned native application.", + "stresses": ["flex", "talkback", "materials", "assets", "viewport", "tables", "accessibility", "recovery"], + "assertions": ["no-legacy-ui-path", "id-driven-workflows", "matched-screenshot-set", "at-spi", "keyboard-equivalence", "no-rough-edges"] } ] } diff --git a/contracts/components.json b/contracts/components.json index 8807b3d..0b33c97 100644 --- a/contracts/components.json +++ b/contracts/components.json @@ -24,6 +24,12 @@ {"id":"rich-text","family":"content","name":"RichText","milestone":2,"status":"planned","semantic_roles":["text","link"],"inputs":["pointer","keyboard","touch"],"adaptive":["locale","direction","text_scale"]}, {"id":"image","family":"content","name":"Image","milestone":2,"status":"planned","semantic_roles":["image"],"inputs":[],"adaptive":["density","contrast"]}, {"id":"icon","family":"content","name":"Icon","milestone":2,"status":"planned","semantic_roles":["image","none"],"inputs":[],"adaptive":["density","contrast","direction"]}, + {"id":"svg","family":"content","name":"Svg","milestone":2,"status":"planned","semantic_roles":["image","none"],"inputs":[],"adaptive":["density","contrast","direction","color_space"]}, + {"id":"png-image","family":"content","name":"PngImage","milestone":2,"status":"planned","semantic_roles":["image"],"inputs":[],"adaptive":["density","contrast","color_space"]}, + {"id":"material","family":"effects","name":"Material","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["contrast","color_space","reduced_transparency"]}, + {"id":"glass","family":"effects","name":"Glass","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["contrast","reduced_motion","reduced_transparency"]}, + {"id":"shadow","family":"effects","name":"Shadow","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["density","contrast","color_space"]}, + {"id":"light","family":"effects","name":"Light","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["contrast","color_space","reduced_transparency"]}, {"id":"divider","family":"content","name":"Divider","milestone":2,"status":"planned","semantic_roles":["separator"],"inputs":[],"adaptive":["density","contrast","direction"]}, {"id":"button","family":"actions","name":"Button","milestone":2,"status":"planned","semantic_roles":["button"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","contrast","reduced_motion"]}, {"id":"toggle","family":"actions","name":"Toggle","milestone":2,"status":"planned","semantic_roles":["switch"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","contrast","reduced_motion"]}, diff --git a/docs/design/visual-direction.md b/docs/design/visual-direction.md index 4e2179b..8c9f12a 100644 --- a/docs/design/visual-direction.md +++ b/docs/design/visual-direction.md @@ -6,8 +6,10 @@ test infrastructure, and benchmark harnesses Zagkit needs its own adaptive design language before polished components are -implemented. It should feel precise, calm, fluid, and capable without becoming -an imitation of Apple, Material, Fluent, or a desktop widget theme. +implemented. It should feel precise, calm, fluid, and capable, with +SwiftUI-class motion and current Apple-class material coherence, without +copying a private implementation or becoming an imitation of Apple, Material, +Fluent, or a desktop widget theme. ## Required decision package @@ -16,6 +18,10 @@ content and interactions. Each direction includes: - semantic color, typography, spacing, shape, elevation, material, motion, and icon token proposals; +- physically coherent lighting, soft-shadow, and liquid-glass proposals with + reduced-transparency and CPU-oracle equivalents; +- crisp font, curve, SVG, and color-managed PNG evidence at every declared + scale factor; - compact, medium, and expanded density behavior; - desktop pointer and keyboard plus mobile touch examples; - light, dark, high contrast, RTL, large text, reduced transparency, and reduced @@ -23,7 +29,9 @@ content and interactions. Each direction includes: - text field, button, menu, dialog, navigation, list, table, and viewport chrome; - interrupt, reversal, gesture handoff, focus, error, disabled, selection, and loading states; -- typography evidence across Latin, Arabic, Hebrew, Indic, CJK, Thai, and emoji. +- typography evidence across Latin, Arabic, Hebrew, Indic, CJK, Thai, and emoji; +- representative PrismStudio viewport, properties, table, menu, dialog, and + command-palette states using identical content in all three directions. ## Selection criteria @@ -39,3 +47,7 @@ Maintainer and accessibility review accept one direction through an RFC. The review names known risks and required conformance scenes. Until then, [the component inventory](../../contracts/components.json) remains `planned` and no screenshot can promote a component. + +Selection is exactly three directions. After selection, visual production must +match the accepted target; style is not improvised independently per component +or PrismStudio screen. diff --git a/docs/milestones/0000-product-contract.md b/docs/milestones/0000-product-contract.md index 1734130..eb0a978 100644 --- a/docs/milestones/0000-product-contract.md +++ b/docs/milestones/0000-product-contract.md @@ -19,6 +19,8 @@ | Upstream prerequisites | contracts/upstream-zag.json | required entry and evidence validation | | Visual direction pause | docs/design/visual-direction.md | blocked gate validation | | CI contract gate | tools/check-contracts.sh | local and GitHub Actions execution | +| Durable goal checklist | GOAL.md | stable ID, evidence, and exit-condition validation | +| Expanded toolkit contract | RFC 0006 | Flex, Talkback, visual fidelity, and PrismStudio validation | ## Zag audit boundary diff --git a/docs/quality/release-gates.md b/docs/quality/release-gates.md index e68a78d..e72e0fa 100644 --- a/docs/quality/release-gates.md +++ b/docs/quality/release-gates.md @@ -28,6 +28,24 @@ GPU output is compared to CPU output with versioned tolerance rules by display list operation, pixel format, color space, and device. An unexplained mismatch fails. Updating a golden requires a reviewed intent record. +Native screenshot comparisons cover crisp typography, curves, SVG, PNG, +lighting, shadows, and transparent materials at every declared scale. Each +comparison records viewport, state, backend, color space, scale, fonts, theme, +contrast, direction, text scale, motion, and transparency preferences. +Screenshots remain necessary visual evidence, not complete product evidence. + +## Flex and Zagkit Talkback + +Flex conformance covers gap, padding, alignment, distribution, baseline, wrap, +intrinsic size, constraints, grid, overlay, breakpoints, safe areas, density, +RTL, and large text. Every layout change reports its exact state read and rule. + +Zagkit Talkback conformance drives canonical applications by stable ID and +checks discovery, query, action, wait, assertion, screenshot, timeline, +capability, snapshot, and replay behavior. Pixel fallback is separately +reported, scale-aware, and forbidden where a required actionable semantic node +should exist. This automation never replaces native assistive-technology runs. + ## Live platform suite Each promoted target runs resize, suspend and resume, background and foreground, @@ -80,3 +98,8 @@ known unsupported features, and untested hardware. | Beta | intended API plus native accessibility, text input, recovery, performance, and packaging evidence | | Stable | complete common gates, supported version policy, reproducible signed artifacts, and support process | | 1.0 | Stable gate on all five required platform families plus PrismStudio and mobile reference proof | + +The PrismStudio proof is a complete UI replacement on Zagkit, not a sample +screen or partial shell migration. Linux polish additionally requires zero open +severity-one or severity-two visual, spacing, text, input, accessibility, +automation, recovery, performance, or packaging defects. diff --git a/docs/rfcs/0006-flex-talkback-visual-fidelity-and-prismstudio.md b/docs/rfcs/0006-flex-talkback-visual-fidelity-and-prismstudio.md new file mode 100644 index 0000000..1cce99b --- /dev/null +++ b/docs/rfcs/0006-flex-talkback-visual-fidelity-and-prismstudio.md @@ -0,0 +1,103 @@ +# RFC 0006: Flex, Talkback, visual fidelity, and PrismStudio + +- Status: Accepted +- Decision date: 2026-08-06 +- Owners: Zagkit maintainers + +## Decision + +Zagkit includes a first-party placement system named **Flex**, a first-party +native automation and inspection system named **Zagkit Talkback**, and a modern +adaptive visual-material system. The complete PrismStudio interface will be +rebuilt on these public Zagkit contracts and is required product proof. + +These are core architecture and release requirements. They are not examples, +optional packages, post-1.0 polish, or PrismStudio-only helpers. + +## Flex + +Flex is the public placement and spacing contract for stacks, wrapping rows and +columns, grids, overlays, scroll content, safe areas, and adaptive composition. +It owns typed length and spacing tokens, gap, padding, alignment, distribution, +baseline alignment, wrapping, minimum and maximum constraints, priorities, +intrinsic sizes, breakpoints, and layout transitions. + +All values are density-independent and resolve through explicit platform scale, +text scale, direction, safe-area, and environment inputs. RTL changes logical +start and end, not physical truth. Stable layout inspection reports the node, +resolved inputs, state read, rule, and ancestor that caused every measurement or +placement. Product screens must compose public Flex rules and semantic tokens; +unexplained per-screen offsets are conformance failures. + +## Zagkit Talkback + +Zagkit Talkback is the Playwright-equivalent control plane for native Zagkit +applications. It uses the same retained node and semantics truth as rendering +and accessibility. Developer-assigned IDs are preferred; deterministic +generated IDs are available for framework-owned nodes. IDs are scoped by app, +window, and retained node identity and have defined behavior across +virtualization, navigation, reload, replacement, and stale references. + +The versioned protocol supports discovery, semantic and property queries, +click, type, key, focus, scroll, drag, gesture, wait, assertion, screenshot, +timeline, capability report, snapshot, and deterministic replay. Normal agent +automation targets IDs. Pixel coordinates are an explicit, scale-aware fallback +for canvases or unavailable semantics; every fallback is recorded and never +reported as an ID action. Failed actions return structured candidates, geometry, +semantics, capability truth, and timeout evidence. + +The product name is always qualified as **Zagkit Talkback** in contexts where it +could be confused with **Android TalkBack**, the assistive technology. Zagkit +Talkback does not replace AT-SPI, VoiceOver, Narrator, or Android TalkBack tests. + +## Visual and asset fidelity + +Zagkit's renderer owns scalable curves, anti-aliasing, subpixel-aware placement, +color management, font rasterization, SVG, PNG, gradients, masks, filters, +lighting, soft shadows, and adaptive materials. SVG and PNG decoders are +bounded, fuzzed, color-managed, and never delegated to a foreign UI engine. + +The visual language may use liquid-glass materials: backdrop sampling, blur, +tint, refraction or distortion, edge highlights, specular response, depth, and +motion must form one coherent material model. Text and controls remain legible +over changing content. Reduced transparency, high contrast, reduced motion, and +CPU fallback have designed equivalents. Effects must remain crisp across every +declared scale and may not collapse into hard, pixelated approximations. + +The target is competitive capability and finish, including SwiftUI-class +fluidity and current Apple-class material coherence, without copying Apple's +private implementation or making Zagkit an imitation of a platform skin. + +## PrismStudio proof + +Milestone 4 is a complete visible UI replacement, not a partial migration. +Zagkit must own the supported Linux shell, placement, styling, assets, +components, semantics, focus, input, and automation. Existing CAD workflows, +keyboard control, direct manipulation, tables, trees, menus, dialogs, viewport, +performance evidence, and GPU safety boundaries remain functional while the +experience is redesigned to the selected Zagkit direction. + +Every actionable node receives a stable Talkback ID. Canonical tasks run by ID, +with pixel fallback permitted only for declared canvas interactions and always +reported. Native screenshots at matched state, viewport, scale, theme, and +effect settings are compared to approved direction images and CPU goldens. +Screenshots are necessary visual proof but do not replace interaction, +semantics, accessibility, recovery, or performance evidence. + +Linux is the first polish reference. Its preview cannot be called polished +while known severity-one or severity-two defects remain in visual fidelity, +spacing, text, input, accessibility, recovery, automation, or packaging. + +## Visual selection gate + +Before visual component or PrismStudio production, maintainers compare exactly +three materially different directions using the same representative CAD and +component states. Review covers light, dark, high contrast, large text, RTL, +reduced motion, and reduced transparency. One direction is accepted by RFC; +implementation then follows that target rather than inventing style per screen. + +## Evidence + +The durable itemized exit conditions live in [the master goal checklist](../../GOAL.md). +Removing or weakening those items requires an RFC amendment. Capability records +remain fail-closed until executable evidence exists. diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md index e000abb..7efec5a 100644 --- a/docs/rfcs/README.md +++ b/docs/rfcs/README.md @@ -8,6 +8,7 @@ | [0003](0003-text-semantics-and-input.md) | Text, semantics, and input ownership | Accepted | | [0004](0004-platform-seams-and-backend-truth.md) | Platform seams and backend truth | Accepted | | [0005](0005-quality-and-release-contract.md) | Quality and release contract | Accepted | +| [0006](0006-flex-talkback-visual-fidelity-and-prismstudio.md) | Flex, Talkback, visual fidelity, and PrismStudio | Accepted | Accepted RFCs are decisions, not proof that implementation exists. Current runtime truth remains in [the platform capability record](../../contracts/platforms.json). diff --git a/tools/check-contracts.sh b/tools/check-contracts.sh index adb495b..29b5c5b 100755 --- a/tools/check-contracts.sh +++ b/tools/check-contracts.sh @@ -21,7 +21,7 @@ require_json() { command -v jq >/dev/null 2>&1 || fail "jq is required to validate repository contracts" for path in \ - LICENSE README.md CHANGELOG.md CONTRIBUTING.md GOVERNANCE.md VERSIONING.md \ + LICENSE README.md GOAL.md CHANGELOG.md CONTRIBUTING.md GOVERNANCE.md VERSIONING.md \ SUPPORT.md SECURITY.md DEPENDENCIES.md ROADMAP.md zag.mod \ docs/design/visual-direction.md docs/quality/release-gates.md \ docs/architecture/README.md docs/milestones/0000-product-contract.md \ @@ -35,11 +35,30 @@ for path in \ docs/rfcs/0002-declarative-core-and-rendering.md \ docs/rfcs/0003-text-semantics-and-input.md \ docs/rfcs/0004-platform-seams-and-backend-truth.md \ - docs/rfcs/0005-quality-and-release-contract.md; do + docs/rfcs/0005-quality-and-release-contract.md \ + docs/rfcs/0006-flex-talkback-visual-fidelity-and-prismstudio.md; do require_file "$path" grep -q 'Status: Accepted' "$path" || fail "$path is not accepted" done +grep -q 'Goal status: \*\*Active\*\*' GOAL.md || fail "master goal is not active" +checkbox_count=$(grep -Ec '^- \[[ x]\] `[A-Z0-9][A-Z0-9-]+` ' GOAL.md || true) +[ "$checkbox_count" -ge 80 ] || fail "master goal checklist lost required coverage" +checklist_ids=$(grep -E '^- \[[ x]\] `[A-Z0-9][A-Z0-9-]+` ' GOAL.md | sed -E 's/^- \[[ x]\] `([^`]+)`.*/\1/') +unique_checklist_ids=$(printf '%s\n' "$checklist_ids" | sort -u | wc -l | tr -d ' ') +[ "$unique_checklist_ids" -eq "$checkbox_count" ] || fail "master goal checklist IDs are not unique" +if grep -E '^- \[x\] ' GOAL.md | grep -vq ' — Evidence: '; then + fail "checked goal item is missing Evidence" +fi +if grep -E '^- \[ \] ' GOAL.md | grep -vq ' — Exit: '; then + fail "unchecked goal item is missing an Exit condition" +fi +for id in G1-SOURCE-FIRST G2-FLEX G3-FONTS G3-SVG G3-PNG G3-GLASS \ + G4-TALKBACK-IDS G4-TALKBACK-PIXELS G5-LINUX-POLISH G6-SHELL \ + G6-SCREENSHOTS G6-POLISH G7-ONE-POINT-ZERO; do + printf '%s\n' "$checklist_ids" | grep -qx "$id" || fail "master goal is missing $id" +done + for path in contracts/toolchain.json contracts/platforms.json \ contracts/upstream-zag.json contracts/components.json \ contracts/benchmark-scenes.json; do From 15cdb8f1c76ca1802480ead072db143d809934f8 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 03:03:07 -0700 Subject: [PATCH 003/103] Record verified Zag resource embedding revision --- contracts/toolchain.json | 7 ++++--- contracts/upstream-zag.json | 6 +++--- docs/milestones/0000-product-contract.md | 17 ++++++++++------- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/contracts/toolchain.json b/contracts/toolchain.json index 7b70953..6e26364 100644 --- a/contracts/toolchain.json +++ b/contracts/toolchain.json @@ -4,15 +4,16 @@ "zag": { "repository": "https://github.com/Sylorlabs/zag", "resolved_ref": "refs/heads/zag-v2-machine-control", - "commit": "67cad46feb6a6d912b8d599d7ed3ade7e81175c5", + "commit": "fe465ead349bc92353dcc82e7ce398665241a568", "compiler_version": "2026.07.0-dev", "edition": "2026" }, "audited_on": "2026-08-06", "pin_policy": "Every Zagkit release replaces this record with the exact Zag source commit used to build and test it. Branch names and local dirty state are never release identities.", "evidence": [ - "Zag origin/zag-v2-machine-control resolved to 67cad46feb6a6d912b8d599d7ed3ade7e81175c5 on 2026-08-06.", + "Zag commit fe465ead349bc92353dcc82e7ce398665241a568 was exported from the exact clean Git tree and audited on 2026-08-06.", "zag-poc/zag.mod at the pinned commit declares version 2026.07.0-dev and edition 2026.", - "The pinned commit documents x86-64 Linux as supported and ARM64 Linux as experimental with qemu-user execution evidence." + "The clean pinned tree self-hosted to a byte-identical stage-2/stage-3 fixpoint with no external tools and rebuilt zagd.", + "The clean pinned tree passed resource embedding on x86-64 and qemu-user ARM64 plus foreground-cache, cache-integration, and tooling gates." ] } diff --git a/contracts/upstream-zag.json b/contracts/upstream-zag.json index 5cabbf8..63d7a1e 100644 --- a/contracts/upstream-zag.json +++ b/contracts/upstream-zag.json @@ -1,7 +1,7 @@ { "schema_version": 1, "audited_repository": "https://github.com/Sylorlabs/zag", - "audited_commit": "67cad46feb6a6d912b8d599d7ed3ade7e81175c5", + "audited_commit": "fe465ead349bc92353dcc82e7ce398665241a568", "audited_on": "2026-08-06", "state_vocabulary": ["available", "partial", "missing"], "evidence_rule": "Available requires native executable conformance at the audited commit. Source presence, a cross compile, or a dirty working tree does not qualify.", @@ -89,9 +89,9 @@ { "id": "resource-embedding", "category": "build-system", - "state": "missing", + "state": "partial", "needed_by": [3, 5, 6, 7], - "evidence": "No compiler owned resource embedding contract or conformance suite exists at the pinned commit.", + "evidence": "The exact clean pinned commit defines compiler-owned #embed, source-relative identity, binary and empty resources, structured E0017 failures, foreground-cache byte identity, deterministic x86-64 execution, and qemu-user ARM64 execution through zag-poc/tests/run_resource_embed.sh. The clean tree also reached a byte-identical self-host fixpoint.", "exit_gate": "Deterministic byte resources embed into every supported object format with identity, alignment, lookup, reproducibility, and malformed input tests." }, { diff --git a/docs/milestones/0000-product-contract.md b/docs/milestones/0000-product-contract.md index eb0a978..b1ea082 100644 --- a/docs/milestones/0000-product-contract.md +++ b/docs/milestones/0000-product-contract.md @@ -25,16 +25,19 @@ ## Zag audit boundary The release pin resolves the clean Zag `zag-v2-machine-control` commit -`67cad46feb6a6d912b8d599d7ed3ade7e81175c5`. The neighboring local Zag checkout -contains extensive unrelated changes on another branch. Those changes were not -used to claim capabilities or construct a release identity. +`fe465ead349bc92353dcc82e7ce398665241a568`. It was exported from the exact Git +tree, self-hosted to a byte-identical stage-2/stage-3 fixpoint without external +tools, and passed the resource, foreground-cache, cache-integration, and tooling +gates. The neighboring local Zag checkout still contains extensive unrelated +changes; those changes were not used to claim capabilities or construct this +release identity. At the pinned commit, Zag documents x86-64 Linux as supported and ARM64 Linux as experimental through qemu-user execution. Mach-O, PE/COFF, iOS, Android, the -platform ABIs, general foreign callbacks and aggregates, resource embedding, -cross-platform dynamic loading, complete concurrency, package resolution, and -stable reload hooks remain missing or partial as recorded in the upstream -ledger. +platform ABIs, general foreign callbacks and aggregates, cross-platform resource +object formats, cross-platform dynamic loading, complete concurrency, package +resolution, and stable reload hooks remain missing or partial as recorded in the +upstream ledger. ## Completion meaning From 49df702ee6bd12b710faff3d0d43566c9288981b Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 05:01:35 -0700 Subject: [PATCH 004/103] Implement deterministic Flex foundation --- .gitignore | 1 + README.md | 17 +- contracts/toolchain.json | 6 +- contracts/upstream-zag.json | 2 +- docs/milestones/0000-product-contract.md | 6 +- src/core/geometry.zag | 104 +++++++++ src/layout/flex.zag | 277 +++++++++++++++++++++++ tests/flex_contract.zag | 146 ++++++++++++ tools/test-headless.sh | 20 ++ 9 files changed, 567 insertions(+), 12 deletions(-) create mode 100644 src/core/geometry.zag create mode 100644 src/layout/flex.zag create mode 100644 tests/flex_contract.zag create mode 100755 tools/test-headless.sh diff --git a/.gitignore b/.gitignore index 4b893d8..87acac0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ zag-out/ .zag-cache/ .zagd.lock +.zagd.semantic-ready # Contract and benchmark output artifacts/ diff --git a/README.md b/README.md index 5fdffc9..853f8f3 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ It includes the Flex placement system, Zagkit Talkback native automation, modern materials and asset fidelity, and a complete PrismStudio UI replacement. This repository is at **0.1.0-experimental.0**. It currently contains the -accepted product contract and executable Milestone 0 checks. It does not yet -contain a usable renderer, window shell, component library, or supported -platform backend. Nothing in this repository is a Zagkit 1.0 release. +accepted product contract, executable Milestone 0 checks, and the first +deterministic headless geometry and Flex slice. It does not yet contain a usable +renderer, window shell, component library, or supported platform backend. +Nothing in this repository is a Zagkit 1.0 release. ## What Zagkit owns @@ -39,9 +40,9 @@ is normative in [DEPENDENCIES.md](DEPENDENCIES.md). | Product and architecture contract | accepted | [RFC index](docs/rfcs/README.md) | | Compiler dependency | pinned, prerequisites incomplete | [toolchain lock](contracts/toolchain.json) | | Platform shells | unavailable | [support matrix](SUPPORT.md) | -| Headless core | not implemented | [roadmap](ROADMAP.md) | +| Headless core | experimental geometry, constraints, and single-line Flex foundation | [headless test](tools/test-headless.sh) | | Components and visual language | inventory only, visual review pending | [component inventory](contracts/components.json) | -| Flex and Zagkit Talkback | contract accepted, not implemented | [RFC 0006](docs/rfcs/0006-flex-talkback-visual-fidelity-and-prismstudio.md) | +| Flex and Zagkit Talkback | Flex foundation executing; wrap, grid, overlay, breakpoints, and Talkback remain | [Flex contract](tests/flex_contract.zag) | | Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | Run the repository contract gate with: @@ -54,6 +55,12 @@ The gate validates the release identity, exact Zag revision, required platform families, backend truth states, upstream prerequisite ledger, component inventory, benchmark scene coverage, and the 1.0 block. +Run the deterministic headless foundation test with: + +```sh +./tools/test-headless.sh +``` + ## Build order 1. Advance reusable compiler, ABI, concurrency, package, and platform features diff --git a/contracts/toolchain.json b/contracts/toolchain.json index 6e26364..c6f09c1 100644 --- a/contracts/toolchain.json +++ b/contracts/toolchain.json @@ -4,16 +4,16 @@ "zag": { "repository": "https://github.com/Sylorlabs/zag", "resolved_ref": "refs/heads/zag-v2-machine-control", - "commit": "fe465ead349bc92353dcc82e7ce398665241a568", + "commit": "310e389fbf62b5facab62434e16d38340e978277", "compiler_version": "2026.07.0-dev", "edition": "2026" }, "audited_on": "2026-08-06", "pin_policy": "Every Zagkit release replaces this record with the exact Zag source commit used to build and test it. Branch names and local dirty state are never release identities.", "evidence": [ - "Zag commit fe465ead349bc92353dcc82e7ce398665241a568 was exported from the exact clean Git tree and audited on 2026-08-06.", + "Zag commit 310e389fbf62b5facab62434e16d38340e978277 was exported from the exact clean Git tree and audited on 2026-08-06.", "zag-poc/zag.mod at the pinned commit declares version 2026.07.0-dev and edition 2026.", "The clean pinned tree self-hosted to a byte-identical stage-2/stage-3 fixpoint with no external tools and rebuilt zagd.", - "The clean pinned tree passed resource embedding on x86-64 and qemu-user ARM64 plus foreground-cache, cache-integration, and tooling gates." + "The clean pinned tree passed resource embedding and aggregate switch value-flow witnesses on x86-64 and qemu-user ARM64 plus foreground-cache, cache-integration, and tooling gates." ] } diff --git a/contracts/upstream-zag.json b/contracts/upstream-zag.json index 63d7a1e..8db3f38 100644 --- a/contracts/upstream-zag.json +++ b/contracts/upstream-zag.json @@ -1,7 +1,7 @@ { "schema_version": 1, "audited_repository": "https://github.com/Sylorlabs/zag", - "audited_commit": "fe465ead349bc92353dcc82e7ce398665241a568", + "audited_commit": "310e389fbf62b5facab62434e16d38340e978277", "audited_on": "2026-08-06", "state_vocabulary": ["available", "partial", "missing"], "evidence_rule": "Available requires native executable conformance at the audited commit. Source presence, a cross compile, or a dirty working tree does not qualify.", diff --git a/docs/milestones/0000-product-contract.md b/docs/milestones/0000-product-contract.md index b1ea082..471d60d 100644 --- a/docs/milestones/0000-product-contract.md +++ b/docs/milestones/0000-product-contract.md @@ -25,10 +25,10 @@ ## Zag audit boundary The release pin resolves the clean Zag `zag-v2-machine-control` commit -`fe465ead349bc92353dcc82e7ce398665241a568`. It was exported from the exact Git +`310e389fbf62b5facab62434e16d38340e978277`. It was exported from the exact Git tree, self-hosted to a byte-identical stage-2/stage-3 fixpoint without external -tools, and passed the resource, foreground-cache, cache-integration, and tooling -gates. The neighboring local Zag checkout still contains extensive unrelated +tools, and passed the resource, aggregate switch value-flow, foreground-cache, +cache-integration, and tooling gates. The neighboring local Zag checkout still contains extensive unrelated changes; those changes were not used to claim capabilities or construct this release identity. diff --git a/src/core/geometry.zag b/src/core/geometry.zag new file mode 100644 index 0000000..e00c6f4 --- /dev/null +++ b/src/core/geometry.zag @@ -0,0 +1,104 @@ +// Deterministic layout geometry. All values are signed 26.6 fixed-point units: +// 64 units equal one density-independent logical pixel. + +fn unit_scale() i64 { return 64; } +fn unbounded_extent() i64 { return 1125899906842624; } + +struct Size { + width: i64, + height: i64, +} + +struct Rect { + x: i64, + y: i64, + width: i64, + height: i64, +} + +struct Insets { + top: i64, + right: i64, + bottom: i64, + left: i64, +} + +struct Constraints { + min_width: i64, + max_width: i64, + min_height: i64, + max_height: i64, +} + +fn size(width: i64, height: i64) Size { + return Size{ .width = width, .height = height }; +} + +fn rect(x: i64, y: i64, width: i64, height: i64) Rect { + return Rect{ .x = x, .y = y, .width = width, .height = height }; +} + +fn insets_all(value: i64) Insets { + let v: i64 = value; + if (v < 0) { v = 0; } + return Insets{ .top = v, .right = v, .bottom = v, .left = v }; +} + +fn clamp_i64(value: i64, minimum: i64, maximum: i64) i64 { + let lo: i64 = minimum; + let hi: i64 = maximum; + if (lo < 0) { lo = 0; } + if (hi < lo) { hi = lo; } + if (value < lo) { return lo; } + if (value > hi) { return hi; } + return value; +} + +fn normalize_constraints(value: Constraints) Constraints { + let min_width: i64 = value.min_width; + let min_height: i64 = value.min_height; + if (min_width < 0) { min_width = 0; } + if (min_height < 0) { min_height = 0; } + + let max_width: i64 = value.max_width; + let max_height: i64 = value.max_height; + if (max_width < min_width) { max_width = min_width; } + if (max_height < min_height) { max_height = min_height; } + + return Constraints{ + .min_width = min_width, + .max_width = max_width, + .min_height = min_height, + .max_height = max_height, + }; +} + +fn tight_constraints(width: i64, height: i64) Constraints { + let w: i64 = width; + let h: i64 = height; + if (w < 0) { w = 0; } + if (h < 0) { h = 0; } + return Constraints{ .min_width = w, .max_width = w, .min_height = h, .max_height = h }; +} + +fn constrain_size(value: Size, raw: Constraints) Size { + let c: Constraints = normalize_constraints(raw); + return Size{ + .width = clamp_i64(value.width, c.min_width, c.max_width), + .height = clamp_i64(value.height, c.min_height, c.max_height), + }; +} + +fn rect_contains(value: Rect, x: i64, y: i64) i32 { + if (value.width <= 0 || value.height <= 0) { return 0; } + if (x < value.x || y < value.y) { return 0; } + if (x >= value.x + value.width || y >= value.y + value.height) { return 0; } + return 1; +} + +fn rect_intersects(a: Rect, b: Rect) i32 { + if (a.width <= 0 || a.height <= 0 || b.width <= 0 || b.height <= 0) { return 0; } + if (a.x + a.width <= b.x || b.x + b.width <= a.x) { return 0; } + if (a.y + a.height <= b.y || b.y + b.height <= a.y) { return 0; } + return 1; +} diff --git a/src/layout/flex.zag b/src/layout/flex.zag new file mode 100644 index 0000000..46a54cd --- /dev/null +++ b/src/layout/flex.zag @@ -0,0 +1,277 @@ +@import("std:list") +@import("../core/geometry.zag") + +enum FlexAxis { row, column } +enum LayoutDirection { ltr, rtl } +enum MainAlignment { start, center, end, space_between, space_around, space_evenly } +enum CrossAlignment { start, center, end, stretch, baseline } + +struct FlexItem { + id: i64, + basis: i64, + grow: i64, + shrink: i64, + min_main: i64, + max_main: i64, + cross: i64, + baseline: i64, +} + +struct FlexStyle { + axis: FlexAxis, + direction: LayoutDirection, + main_alignment: MainAlignment, + cross_alignment: CrossAlignment, + gap: i64, + padding: Insets, +} + +struct FlexPlacement { + id: i64, + frame: Rect, + baseline: i64, +} + +struct FlexResult { + placements: ArrayList[FlexPlacement], + container: Size, + overflow: i64, + duplicate_ids: i32, +} + +fn flex_item(id: i64, basis: i64, cross: i64) FlexItem { + return FlexItem{ + .id = id, + .basis = basis, + .grow = 0, + .shrink = 1, + .min_main = 0, + .max_main = unbounded_extent(), + .cross = cross, + .baseline = cross, + }; +} + +fn flex_style(axis: FlexAxis, gap: i64, padding: Insets) FlexStyle { + let actual_gap: i64 = gap; + if (actual_gap < 0) { actual_gap = 0; } + return FlexStyle{ + .axis = axis, + .direction = LayoutDirection.ltr, + .main_alignment = MainAlignment.start, + .cross_alignment = CrossAlignment.start, + .gap = actual_gap, + .padding = padding, + }; +} + +fn flex_free(result: *FlexResult) void { + free[FlexPlacement](&result.*.placements); +} + +fn flex_main_min(c: Constraints, axis: FlexAxis) i64 { + return switch (axis) { .row => c.min_width, .column => c.min_height }; +} + +fn flex_main_max(c: Constraints, axis: FlexAxis) i64 { + return switch (axis) { .row => c.max_width, .column => c.max_height }; +} + +fn flex_cross_min(c: Constraints, axis: FlexAxis) i64 { + return switch (axis) { .row => c.min_height, .column => c.min_width }; +} + +fn flex_cross_max(c: Constraints, axis: FlexAxis) i64 { + return switch (axis) { .row => c.max_height, .column => c.max_width }; +} + +fn flex_main_padding(p: Insets, axis: FlexAxis) i64 { + return switch (axis) { .row => p.left + p.right, .column => p.top + p.bottom }; +} + +fn flex_cross_padding(p: Insets, axis: FlexAxis) i64 { + return switch (axis) { .row => p.top + p.bottom, .column => p.left + p.right }; +} + +fn flex_has_duplicate_id(items: ArrayList[FlexItem]) i32 { + let i: i32 = 0; + while (i < items.len) { + let j: i32 = i + 1; + while (j < items.len) { + if (items.data[i].id == items.data[j].id) { return 1; } + j = j + 1; + } + i = i + 1; + } + return 0; +} + +fn flex_item_main(item: FlexItem) i64 { + return clamp_i64(item.basis, item.min_main, item.max_main); +} + +fn flex_layout(items: ArrayList[FlexItem], raw_constraints: Constraints, raw_style: FlexStyle) FlexResult { + let constraints: Constraints = normalize_constraints(raw_constraints); + let style: FlexStyle = raw_style; + if (style.gap < 0) { style.gap = 0; } + if (style.padding.top < 0) { style.padding.top = 0; } + if (style.padding.right < 0) { style.padding.right = 0; } + if (style.padding.bottom < 0) { style.padding.bottom = 0; } + if (style.padding.left < 0) { style.padding.left = 0; } + + let main_padding: i64 = flex_main_padding(style.padding, style.axis); + let cross_padding: i64 = flex_cross_padding(style.padding, style.axis); + let gap_total: i64 = 0; + if (items.len > 1) { gap_total = style.gap * ((items.len - 1) as i64); } + + let base_total: i64 = main_padding + gap_total; + let desired_cross: i64 = cross_padding; + let grow_total: i64 = 0; + let shrink_total: i64 = 0; + let i: i32 = 0; + while (i < items.len) { + base_total = base_total + flex_item_main(items.data[i]); + if (items.data[i].grow > 0) { grow_total = grow_total + items.data[i].grow; } + if (items.data[i].shrink > 0) { shrink_total = shrink_total + items.data[i].shrink; } + let item_cross: i64 = items.data[i].cross; + if (item_cross > desired_cross - cross_padding) { desired_cross = cross_padding + item_cross; } + i = i + 1; + } + + let main_limit: i64 = flex_main_max(constraints, style.axis); + let container_main: i64 = base_total; + if (main_limit < unbounded_extent()) { container_main = main_limit; } + container_main = clamp_i64(container_main, flex_main_min(constraints, style.axis), main_limit); + let container_cross: i64 = clamp_i64(desired_cross, flex_cross_min(constraints, style.axis), flex_cross_max(constraints, style.axis)); + + let main_sizes: ArrayList[i64] = make[i64](items.len); + i = 0; + while (i < items.len) { push[i64](&main_sizes, flex_item_main(items.data[i])); i = i + 1; } + + let delta: i64 = container_main - base_total; + if (delta > 0 && grow_total > 0) { + let distributed: i64 = 0; + let last_grow: i32 = 0 - 1; + i = 0; + while (i < items.len) { if (items.data[i].grow > 0) { last_grow = i; } i = i + 1; } + i = 0; + while (i < items.len) { + if (items.data[i].grow > 0) { + let share: i64 = 0; + if (i == last_grow) { share = delta - distributed; } + else { share = (delta * items.data[i].grow) / grow_total; } + let old_size: i64 = main_sizes.data[i]; + main_sizes.data[i] = clamp_i64(old_size + share, items.data[i].min_main, items.data[i].max_main); + distributed = distributed + (main_sizes.data[i] - old_size); + } + i = i + 1; + } + } + if (delta < 0 && shrink_total > 0) { + let need: i64 = 0 - delta; + let removed: i64 = 0; + let last_shrink: i32 = 0 - 1; + i = 0; + while (i < items.len) { if (items.data[i].shrink > 0) { last_shrink = i; } i = i + 1; } + i = 0; + while (i < items.len) { + if (items.data[i].shrink > 0) { + let share: i64 = 0; + if (i == last_shrink) { share = need - removed; } + else { share = (need * items.data[i].shrink) / shrink_total; } + let old_size: i64 = main_sizes.data[i]; + main_sizes.data[i] = clamp_i64(old_size - share, items.data[i].min_main, items.data[i].max_main); + removed = removed + (old_size - main_sizes.data[i]); + } + i = i + 1; + } + } + + let used_main: i64 = main_padding + gap_total; + i = 0; + while (i < main_sizes.len) { used_main = used_main + main_sizes.data[i]; i = i + 1; } + let remaining: i64 = container_main - used_main; + let positive_remaining: i64 = remaining; + if (positive_remaining < 0) { positive_remaining = 0; } + let leading: i64 = 0; + let extra_gap: i64 = 0; + switch (style.main_alignment) { + .start => { } + .center => { leading = positive_remaining / 2; } + .end => { leading = positive_remaining; } + .space_between => { if (items.len > 1) { extra_gap = positive_remaining / ((items.len - 1) as i64); } } + .space_around => { + if (items.len > 0) { extra_gap = positive_remaining / (items.len as i64); leading = extra_gap / 2; } + } + .space_evenly => { + if (items.len > 0) { extra_gap = positive_remaining / ((items.len + 1) as i64); leading = extra_gap; } + } + } + + let max_baseline: i64 = 0; + if (style.axis == FlexAxis.row && style.cross_alignment == CrossAlignment.baseline) { + i = 0; + while (i < items.len) { + let baseline: i64 = clamp_i64(items.data[i].baseline, 0, items.data[i].cross); + if (baseline > max_baseline) { max_baseline = baseline; } + i = i + 1; + } + } + + let placements: ArrayList[FlexPlacement] = make[FlexPlacement](items.len); + let cursor: i64 = leading; + i = 0; + while (i < items.len) { + let item: FlexItem = items.data[i]; + let main_size: i64 = main_sizes.data[i]; + let inner_cross: i64 = container_cross - cross_padding; + let cross_size: i64 = clamp_i64(item.cross, 0, inner_cross); + if (style.cross_alignment == CrossAlignment.stretch) { cross_size = inner_cross; } + let cross_offset: i64 = 0; + switch (style.cross_alignment) { + .start => { } + .center => { cross_offset = (inner_cross - cross_size) / 2; } + .end => { cross_offset = inner_cross - cross_size; } + .stretch => { } + .baseline => { + if (style.axis == FlexAxis.row) { + cross_offset = max_baseline - clamp_i64(item.baseline, 0, cross_size); + } + } + } + + let frame: Rect = rect(0, 0, 0, 0); + if (style.axis == FlexAxis.row) { + frame.width = main_size; + frame.height = cross_size; + frame.y = style.padding.top + cross_offset; + if (style.direction == LayoutDirection.ltr) { frame.x = style.padding.left + cursor; } + else { frame.x = container_main - style.padding.right - cursor - main_size; } + } else { + frame.width = cross_size; + frame.height = main_size; + frame.y = style.padding.top + cursor; + let logical_cross: i64 = cross_offset; + if (style.direction == LayoutDirection.ltr) { frame.x = style.padding.left + logical_cross; } + else { frame.x = container_cross - style.padding.right - logical_cross - cross_size; } + } + push[FlexPlacement](&placements, FlexPlacement{ .id = item.id, .frame = frame, .baseline = frame.y + clamp_i64(item.baseline, 0, frame.height) }); + cursor = cursor + main_size; + if (i + 1 < items.len) { cursor = cursor + style.gap + extra_gap; } + i = i + 1; + } + + free[i64](&main_sizes); + let output_size: Size = switch (style.axis) { + .row => Size{ .width = container_main, .height = container_cross }, + .column => Size{ .width = container_cross, .height = container_main }, + }; + let overflow: i64 = used_main - container_main; + if (overflow < 0) { overflow = 0; } + return FlexResult{ + .placements = placements, + .container = output_size, + .overflow = overflow, + .duplicate_ids = flex_has_duplicate_id(items), + }; +} diff --git a/tests/flex_contract.zag b/tests/flex_contract.zag new file mode 100644 index 0000000..f77324b --- /dev/null +++ b/tests/flex_contract.zag @@ -0,0 +1,146 @@ +@import("std:list") +@import("../src/layout/flex.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); + _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); + _zag_println(name); + } +} + +fn report_i64(label: []u8, value: i64) void { + _zag_print(label); + _zag_println(_zag_i64_to_str(value)); +} + +fn item(id: i64, basis: i64, cross: i64) FlexItem { return flex_item(id, basis, cross); } + +fn fixed_row(state: *TestState) void { + let items: ArrayList[FlexItem] = make[FlexItem](3); + push[FlexItem](&items, item(1, 20, 10)); + push[FlexItem](&items, item(2, 30, 12)); + push[FlexItem](&items, item(3, 10, 8)); + let style: FlexStyle = flex_style(FlexAxis.row, 5, insets_all(4)); + let result: FlexResult = flex_layout(items, tight_constraints(78, 20), style); + expect(state, result.container.width == 78 && result.container.height == 20, "tight constraints define the container"); + expect(state, result.placements.data[0].frame.x == 4, "leading padding is exact"); + expect(state, result.placements.data[1].frame.x == 29, "gap is exact"); + expect(state, result.placements.data[2].frame.x == 64, "third placement is deterministic"); + flex_free(&result); + free[FlexItem](&items); +} + +fn growing_row(state: *TestState) void { + let items: ArrayList[FlexItem] = make[FlexItem](2); + let first: FlexItem = item(10, 20, 10); first.grow = 1; + let second: FlexItem = item(11, 20, 10); second.grow = 2; + push[FlexItem](&items, first); push[FlexItem](&items, second); + let result: FlexResult = flex_layout(items, tight_constraints(100, 10), flex_style(FlexAxis.row, 0, insets_all(0))); + expect(state, result.placements.data[0].frame.width == 40, "grow weight one receives one third"); + expect(state, result.placements.data[1].frame.width == 60, "last grow item receives exact remainder"); + expect(state, result.placements.data[1].frame.x == 40, "growth conserves the main extent"); + flex_free(&result); free[FlexItem](&items); +} + +fn rtl_row(state: *TestState) void { + let items: ArrayList[FlexItem] = make[FlexItem](2); + push[FlexItem](&items, item(1, 20, 10)); push[FlexItem](&items, item(2, 30, 10)); + let style: FlexStyle = flex_style(FlexAxis.row, 5, Insets{ .top = 0, .right = 7, .bottom = 0, .left = 3 }); + style.direction = LayoutDirection.rtl; + let result: FlexResult = flex_layout(items, tight_constraints(65, 10), style); + expect(state, result.placements.data[0].frame.x == 38, "RTL starts at the physical right inset"); + expect(state, result.placements.data[1].frame.x == 3, "RTL preserves logical order and exact gap"); + flex_free(&result); free[FlexItem](&items); +} + +fn alignment(state: *TestState) void { + let items: ArrayList[FlexItem] = make[FlexItem](2); + let a: FlexItem = item(1, 20, 12); a.baseline = 8; + let b: FlexItem = item(2, 20, 18); b.baseline = 14; + push[FlexItem](&items, a); push[FlexItem](&items, b); + let style: FlexStyle = flex_style(FlexAxis.row, 0, insets_all(2)); + style.cross_alignment = CrossAlignment.baseline; + expect(state, style.cross_alignment == CrossAlignment.baseline, "baseline style mutation persists before layout"); + let result: FlexResult = flex_layout(items, tight_constraints(40, 24), style); + if (result.placements.data[0].baseline != result.placements.data[1].baseline || + result.placements.data[0].frame.y != 8 || result.placements.data[1].frame.y != 2) { + report_i64(" first y=", result.placements.data[0].frame.y); + report_i64(" first baseline=", result.placements.data[0].baseline); + report_i64(" second y=", result.placements.data[1].frame.y); + report_i64(" second baseline=", result.placements.data[1].baseline); + } + expect(state, result.placements.data[0].baseline == result.placements.data[1].baseline, "baseline alignment is exact"); + expect(state, result.placements.data[0].frame.y == 8 && result.placements.data[1].frame.y == 2, "baseline offsets remain deterministic"); + flex_free(&result); free[FlexItem](&items); +} + +fn distribution(state: *TestState) void { + let items: ArrayList[FlexItem] = make[FlexItem](3); + push[FlexItem](&items, item(1, 10, 10)); push[FlexItem](&items, item(2, 10, 10)); push[FlexItem](&items, item(3, 10, 10)); + let style: FlexStyle = flex_style(FlexAxis.row, 0, insets_all(0)); + style.main_alignment = MainAlignment.space_between; + let result: FlexResult = flex_layout(items, tight_constraints(90, 10), style); + expect(state, result.placements.data[0].frame.x == 0, "space-between keeps first edge"); + expect(state, result.placements.data[1].frame.x == 40, "space-between distributes the first interval"); + expect(state, result.placements.data[2].frame.x == 80, "space-between keeps last edge"); + flex_free(&result); free[FlexItem](&items); +} + +fn safety_contracts(state: *TestState) void { + let normalized: Constraints = normalize_constraints(Constraints{ .min_width = 20, .max_width = 10, .min_height = -5, .max_height = -10 }); + expect(state, normalized.min_width == 20 && normalized.max_width == 20, "invalid width constraints collapse safely"); + expect(state, normalized.min_height == 0 && normalized.max_height == 0, "negative height constraints clamp safely"); + + let items: ArrayList[FlexItem] = make[FlexItem](2); + let a: FlexItem = item(7, 50, 10); a.min_main = 40; + let b: FlexItem = item(7, 50, 10); b.min_main = 40; + push[FlexItem](&items, a); push[FlexItem](&items, b); + let result: FlexResult = flex_layout(items, tight_constraints(60, 10), flex_style(FlexAxis.row, 0, insets_all(0))); + expect(state, result.duplicate_ids == 1, "duplicate stable IDs fail visibly"); + expect(state, result.overflow == 20, "min-size overflow is explicit"); + flex_free(&result); free[FlexItem](&items); +} + +fn property_sweep(state: *TestState) void { + let width: i64 = 64; + let all_valid: i32 = 1; + while (width <= 256) { + let items: ArrayList[FlexItem] = make[FlexItem](3); + let a: FlexItem = item(1, 8, 10); a.grow = 1; + let b: FlexItem = item(2, 13, 10); b.grow = 2; + let c: FlexItem = item(3, 5, 10); c.grow = 1; + push[FlexItem](&items, a); push[FlexItem](&items, b); push[FlexItem](&items, c); + let result: FlexResult = flex_layout(items, tight_constraints(width, 10), flex_style(FlexAxis.row, 2, insets_all(1))); + let last: Rect = result.placements.data[2].frame; + if (result.overflow != 0 || last.x + last.width != width - 1 || + rect_intersects(result.placements.data[0].frame, result.placements.data[1].frame) != 0 || + rect_intersects(result.placements.data[1].frame, result.placements.data[2].frame) != 0) { + all_valid = 0; + } + flex_free(&result); free[FlexItem](&items); + width = width + 1; + } + expect(state, all_valid, "193-width property sweep conserves bounds and gaps"); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + fixed_row(&state); + growing_row(&state); + rtl_row(&state); + alignment(&state); + distribution(&state); + safety_contracts(&state); + property_sweep(&state); + _zag_print("Flex contract: pass="); _zag_print(_zag_i64_to_str(state.passed as i64)); + _zag_print(" fail="); _zag_println(_zag_i64_to_str(state.failed as i64)); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh new file mode 100755 index 0000000..bc27a4e --- /dev/null +++ b/tools/test-headless.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +znc=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} + +[ -x "$znc" ] || { + printf 'headless test: FAIL: Zag compiler not executable: %s\n' "$znc" >&2 + exit 1 +} + +tmp=$(mktemp -d /tmp/zagkit-headless.XXXXXX) +cleanup() { rm -rf "$tmp"; } +trap cleanup EXIT + +cd "$root" +"$znc" tests/flex_contract.zag --no-zagd --no-analyze --no-foreground-cache -o "$tmp/flex-contract" +"$tmp/flex-contract" + +printf 'headless test: PASS (constraints and Flex)\n' From 23760f6b2beb22cb044d3f5659aed03d3811a583 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 05:10:47 -0700 Subject: [PATCH 005/103] Implement state tracking and keyed reconciliation --- CHANGELOG.md | 5 + README.md | 16 ++- docs/architecture/README.md | 11 +- src/core/reconcile.zag | 122 ++++++++++++++++++++++ src/core/state.zag | 51 ++++++++++ src/core/view_contract.zag | 157 +++++++++++++++++++++++++++++ tests/flex_contract.zag | 12 ++- tests/state_reconcile_contract.zag | 140 +++++++++++++++++++++++++ tools/test-headless.sh | 4 +- 9 files changed, 507 insertions(+), 11 deletions(-) create mode 100644 src/core/reconcile.zag create mode 100644 src/core/state.zag create mode 100644 src/core/view_contract.zag create mode 100644 tests/state_reconcile_contract.zag diff --git a/CHANGELOG.md b/CHANGELOG.md index 7243729..3fdd501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ include a generated capability record and exact Zag compiler revision. - Machine readable platform, component, benchmark, compiler, and upstream prerequisite records. - An executable repository contract gate. +- Experimental typed state and binding primitives with exact dependency-read + invalidation evidence. +- Experimental keyed reconciliation with stable reorder identity and + fail-visible duplicate-key handling. +- Deterministic fixed-point geometry, constraints, and single-line Flex. ## 0.1.0-experimental.0 (unreleased) diff --git a/README.md b/README.md index 853f8f3..15bca7b 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ modern materials and asset fidelity, and a complete PrismStudio UI replacement. This repository is at **0.1.0-experimental.0**. It currently contains the accepted product contract, executable Milestone 0 checks, and the first -deterministic headless geometry and Flex slice. It does not yet contain a usable -renderer, window shell, component library, or supported platform backend. -Nothing in this repository is a Zagkit 1.0 release. +deterministic state, keyed reconciliation, geometry, and Flex slices. It does +not yet contain a usable renderer, window shell, component library, or +supported platform backend. Nothing in this repository is a Zagkit 1.0 release. ## What Zagkit owns @@ -40,7 +40,7 @@ is normative in [DEPENDENCIES.md](DEPENDENCIES.md). | Product and architecture contract | accepted | [RFC index](docs/rfcs/README.md) | | Compiler dependency | pinned, prerequisites incomplete | [toolchain lock](contracts/toolchain.json) | | Platform shells | unavailable | [support matrix](SUPPORT.md) | -| Headless core | experimental geometry, constraints, and single-line Flex foundation | [headless test](tools/test-headless.sh) | +| Headless core | experimental state dependencies, keyed reconciliation, geometry, constraints, and single-line Flex | [headless test](tools/test-headless.sh) | | Components and visual language | inventory only, visual review pending | [component inventory](contracts/components.json) | | Flex and Zagkit Talkback | Flex foundation executing; wrap, grid, overlay, breakpoints, and Talkback remain | [Flex contract](tests/flex_contract.zag) | | Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | @@ -55,6 +55,14 @@ The gate validates the release identity, exact Zag revision, required platform families, backend truth states, upstream prerequisite ledger, component inventory, benchmark scene coverage, and the 1.0 block. +The compiled headless contract currently provides revisioned `State`, +action-producing `Binding`, inherited integer environment values, exact +per-node state-read records, fail-visible keyed reconciliation, deterministic +fixed-point geometry, and single-line Flex. These APIs are experimental. Typed +environment values, reconciliation cancellation, child ownership, replay, +intrinsic measurement, wrapping, grid, overlay, and breakpoints remain open; +the corresponding Milestone 2 checklist items are not complete. + Run the deterministic headless foundation test with: ```sh diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 8b77bac..5a6d371 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -26,6 +26,11 @@ Normative decisions: - [platform seams and backend truth](../rfcs/0004-platform-seams-and-backend-truth.md) - [quality and release](../rfcs/0005-quality-and-release-contract.md) -The public API names in RFC 0002 are semantic reservations, not compiled API. -The first implementation RFC will fix Zag syntax, ownership, threading, error, -and serialization details after the required upstream primitives are proven. +The first experimental compiled slice now fixes the initial Zag shapes for +`NodeKey`, `State`, `Binding`, `Action`, `Environment`, `ViewContext`, +`ViewSpec`, and `RenderNode`. State reads record their reader and revision; +invalidation reports the exact read and revision edge; keyed reconciliation +preserves retained identity through reorder and fails visibly on duplicate +keys. Ownership, threading, serialization, typed environment values, +cancellation, and deterministic replay are still open contracts rather than +stable API. diff --git a/src/core/reconcile.zag b/src/core/reconcile.zag new file mode 100644 index 0000000..f664eca --- /dev/null +++ b/src/core/reconcile.zag @@ -0,0 +1,122 @@ +@import("std:list") +@import("view_contract.zag") + +struct ViewSpec { + key: NodeKey, + kind: i64, + fingerprint: i64, +} + +struct RenderNode { + key: NodeKey, + kind: i64, + fingerprint: i64, + instance_id: i64, + generation: i64, +} + +struct ReconcileResult { + nodes: ArrayList[RenderNode], + inserted: i32, + reused: i32, + updated: i32, + removed: i32, + duplicate_keys: i32, +} + +fn view_spec(key: NodeKey, kind: i64, fingerprint: i64) ViewSpec { + return ViewSpec{ .key = key, .kind = kind, .fingerprint = fingerprint }; +} + +fn render_node(key: NodeKey, kind: i64, fingerprint: i64, instance_id: i64) RenderNode { + return RenderNode{ + .key = key, + .kind = kind, + .fingerprint = fingerprint, + .instance_id = instance_id, + .generation = 0, + }; +} + +fn reconcile_free(result: *ReconcileResult) void { + free[RenderNode](&result.*.nodes); +} + +fn reconcile_has_earlier_key(next: ArrayList[ViewSpec], index: i32) i32 { + let i: i32 = 0; + while (i < index) { + if (node_key_equal(next.data[i].key, next.data[index].key) == 1) { return 1; } + i = i + 1; + } + return 0; +} + +fn reconcile(previous: ArrayList[RenderNode], next: ArrayList[ViewSpec], next_instance_id: *i64) ReconcileResult { + let output: ArrayList[RenderNode] = make[RenderNode](next.len); + let matched: ArrayList[i32] = make[i32](previous.len); + let i: i32 = 0; + while (i < previous.len) { push[i32](&matched, 0); i = i + 1; } + + let inserted: i32 = 0; + let reused: i32 = 0; + let updated: i32 = 0; + let removed: i32 = 0; + let duplicate_keys: i32 = 0; + + i = 0; + while (i < next.len) { + let spec: ViewSpec = next.data[i]; + if (reconcile_has_earlier_key(next, i) == 1) { + duplicate_keys = duplicate_keys + 1; + i = i + 1; + continue; + } + + let found: i32 = 0 - 1; + let j: i32 = 0; + while (j < previous.len) { + if (matched.data[j] == 0 && node_key_equal(previous.data[j].key, spec.key) == 1) { + found = j; + break; + } + j = j + 1; + } + + if (found >= 0 && previous.data[found].kind == spec.kind) { + let node: RenderNode = previous.data[found]; + matched.data[found] = 1; + reused = reused + 1; + if (node.fingerprint != spec.fingerprint) { + node.fingerprint = spec.fingerprint; + node.generation = node.generation + 1; + updated = updated + 1; + } + push[RenderNode](&output, node); + } else { + if (found >= 0) { + matched.data[found] = 1; + removed = removed + 1; + } + let instance_id: i64 = next_instance_id.*; + next_instance_id.* = next_instance_id.* + 1; + push[RenderNode](&output, render_node(spec.key, spec.kind, spec.fingerprint, instance_id)); + inserted = inserted + 1; + } + i = i + 1; + } + + i = 0; + while (i < previous.len) { + if (matched.data[i] == 0) { removed = removed + 1; } + i = i + 1; + } + free[i32](&matched); + return ReconcileResult{ + .nodes = output, + .inserted = inserted, + .reused = reused, + .updated = updated, + .removed = removed, + .duplicate_keys = duplicate_keys, + }; +} diff --git a/src/core/state.zag b/src/core/state.zag new file mode 100644 index 0000000..3e69af7 --- /dev/null +++ b/src/core/state.zag @@ -0,0 +1,51 @@ +@import("view_contract.zag") + +struct State[T] { + id: i64, + revision: i64, + value: T, +} + +struct Binding[T] { + target: *State[T], + action_id: i64, +} + +fn state_make[T](id: i64, value: T) State[T] { + return State[T]{ .id = id, .revision = 0, .value = value }; +} + +fn state_read[T](state: *State[T], ctx: *ViewContext) T { + view_context_record_read(ctx, state.*.id, state.*.revision); + return state.*.value; +} + +fn state_set[T](state: *State[T], value: T) StateChange { + let from_revision: i64 = state.*.revision; + state.*.value = value; + state.*.revision = state.*.revision + 1; + return StateChange{ + .state_id = state.*.id, + .from_revision = from_revision, + .to_revision = state.*.revision, + }; +} + +fn binding_make[T](state: *State[T], action_id: i64) Binding[T] { + return Binding[T]{ .target = state, .action_id = action_id }; +} + +fn binding_read[T](binding: Binding[T], ctx: *ViewContext) T { + return state_read[T](binding.target, ctx); +} + +fn binding_set[T](binding: Binding[T], value: T, source: NodeKey) Action { + let change: StateChange = state_set[T](binding.target, value); + return Action{ + .id = binding.action_id, + .source = source, + .target_state_id = change.state_id, + .from_revision = change.from_revision, + .to_revision = change.to_revision, + }; +} diff --git a/src/core/view_contract.zag b/src/core/view_contract.zag new file mode 100644 index 0000000..1fd54df --- /dev/null +++ b/src/core/view_contract.zag @@ -0,0 +1,157 @@ +@import("std:list") + +struct NodeKey { + value: i64, + generation: i64, +} + +struct StateRead { + reader: NodeKey, + state_id: i64, + revision: i64, +} + +struct StateChange { + state_id: i64, + from_revision: i64, + to_revision: i64, +} + +struct InvalidationCause { + found: i32, + reader: NodeKey, + state_id: i64, + read_revision: i64, + from_revision: i64, + to_revision: i64, +} + +struct Action { + id: i64, + source: NodeKey, + target_state_id: i64, + from_revision: i64, + to_revision: i64, +} + +struct EnvironmentEntry { + key: i64, + value: i64, + revision: i64, +} + +struct Environment { + entries: ArrayList[EnvironmentEntry], + revision: i64, +} + +struct ViewContext { + reader: NodeKey, + reads: ArrayList[StateRead], +} + +fn node_key(value: i64) NodeKey { + return NodeKey{ .value = value, .generation = 0 }; +} + +fn node_key_scoped(value: i64, generation: i64) NodeKey { + return NodeKey{ .value = value, .generation = generation }; +} + +fn node_key_equal(a: NodeKey, b: NodeKey) i32 { + return (a.value == b.value && a.generation == b.generation) as i32; +} + +fn environment_make() Environment { + return Environment{ .entries = make[EnvironmentEntry](4), .revision = 0 }; +} + +fn environment_free(env: *Environment) void { + free[EnvironmentEntry](&env.*.entries); +} + +fn environment_set(env: *Environment, key: i64, value: i64) i64 { + let i: i32 = 0; + while (i < env.*.entries.len) { + if (env.*.entries.data[i].key == key) { + if (env.*.entries.data[i].value != value) { + env.*.revision = env.*.revision + 1; + env.*.entries.data[i].value = value; + env.*.entries.data[i].revision = env.*.revision; + } + return env.*.entries.data[i].revision; + } + i = i + 1; + } + env.*.revision = env.*.revision + 1; + push[EnvironmentEntry](&env.*.entries, EnvironmentEntry{ + .key = key, + .value = value, + .revision = env.*.revision, + }); + return env.*.revision; +} + +fn environment_get(env: Environment, key: i64, fallback: i64) i64 { + let i: i32 = env.entries.len - 1; + while (i >= 0) { + if (env.entries.data[i].key == key) { return env.entries.data[i].value; } + i = i - 1; + } + return fallback; +} + +fn view_context_make(reader: NodeKey) ViewContext { + return ViewContext{ .reader = reader, .reads = make[StateRead](4) }; +} + +fn view_context_free(ctx: *ViewContext) void { + free[StateRead](&ctx.*.reads); +} + +fn view_context_record_read(ctx: *ViewContext, state_id: i64, revision: i64) void { + let i: i32 = 0; + while (i < ctx.*.reads.len) { + if (ctx.*.reads.data[i].state_id == state_id && + node_key_equal(ctx.*.reads.data[i].reader, ctx.*.reader) == 1) { + ctx.*.reads.data[i].revision = revision; + return; + } + i = i + 1; + } + push[StateRead](&ctx.*.reads, StateRead{ + .reader = ctx.*.reader, + .state_id = state_id, + .revision = revision, + }); +} + +fn view_context_invalidation(ctx: ViewContext, change: StateChange) InvalidationCause { + let i: i32 = 0; + while (i < ctx.reads.len) { + let read: StateRead = ctx.reads.data[i]; + if (read.state_id == change.state_id && read.revision < change.to_revision) { + return InvalidationCause{ + .found = 1, + .reader = read.reader, + .state_id = read.state_id, + .read_revision = read.revision, + .from_revision = change.from_revision, + .to_revision = change.to_revision, + }; + } + i = i + 1; + } + return InvalidationCause{ + .found = 0, + .reader = ctx.reader, + .state_id = change.state_id, + .read_revision = 0, + .from_revision = change.from_revision, + .to_revision = change.to_revision, + }; +} + +fn view_context_invalidated(ctx: ViewContext, change: StateChange) i32 { + return view_context_invalidation(ctx, change).found; +} diff --git a/tests/flex_contract.zag b/tests/flex_contract.zag index f77324b..da51d8e 100644 --- a/tests/flex_contract.zag +++ b/tests/flex_contract.zag @@ -16,8 +16,10 @@ fn expect(state: *TestState, condition: i32, name: []u8) void { } fn report_i64(label: []u8, value: i64) void { + let text: []u8 = _zag_i64_to_str(value); _zag_print(label); - _zag_println(_zag_i64_to_str(value)); + _zag_println(text); + _zag_str_free(text); } fn item(id: i64, basis: i64, cross: i64) FlexItem { return flex_item(id, basis, cross); } @@ -139,8 +141,12 @@ fn main() i32 { distribution(&state); safety_contracts(&state); property_sweep(&state); - _zag_print("Flex contract: pass="); _zag_print(_zag_i64_to_str(state.passed as i64)); - _zag_print(" fail="); _zag_println(_zag_i64_to_str(state.failed as i64)); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Flex contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); + _zag_str_free(failed_text); if (state.failed == 0) { return 0; } return 1; } diff --git a/tests/state_reconcile_contract.zag b/tests/state_reconcile_contract.zag new file mode 100644 index 0000000..8480496 --- /dev/null +++ b/tests/state_reconcile_contract.zag @@ -0,0 +1,140 @@ +@import("std:list") +@import("../src/core/state.zag") +@import("../src/core/reconcile.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn state_contract(state: *TestState) void { + let count: State[i64] = state_make[i64](7, 10); + let ctx: ViewContext = view_context_make(node_key(100)); + expect(state, state_read[i64](&count, &ctx) == 10, "state read returns the typed value"); + expect(state, state_read[i64](&count, &ctx) == 10 && ctx.reads.len == 1, "duplicate reads collapse to one exact dependency"); + + let change: StateChange = state_set[i64](&count, 20); + expect(state, change.from_revision == 0 && change.to_revision == 1, "state change records exact revisions"); + expect(state, view_context_invalidated(ctx, change) == 1, "the recorded reader invalidates for its state change"); + let cause: InvalidationCause = view_context_invalidation(ctx, change); + expect(state, cause.found == 1 && cause.reader.value == 100 && cause.state_id == 7 && cause.read_revision == 0 && cause.from_revision == 0 && cause.to_revision == 1, "invalidation names the exact read, reader, and revision edge"); + expect(state, view_context_invalidated(ctx, StateChange{ .state_id = 8, .from_revision = 0, .to_revision = 1 }) == 0, "unread state does not invalidate the reader"); + + let binding: Binding[i64] = binding_make[i64](&count, 55); + let action: Action = binding_set[i64](binding, 30, node_key_scoped(101, 2)); + expect(state, binding_read[i64](binding, &ctx) == 30, "binding reads and writes the target state"); + expect(state, action.id == 55 && action.target_state_id == 7 && action.from_revision == 1 && action.to_revision == 2, "binding emits an auditable action"); + expect(state, view_context_invalidated(ctx, change) == 0, "a fresh read is not invalidated by an older change"); + + view_context_free(&ctx); +} + +fn reconciliation_property_contract(state: *TestState) void { + let previous: ArrayList[RenderNode] = make[RenderNode](5); + let key: i64 = 0; + while (key < 5) { + push[RenderNode](&previous, render_node(node_key(key), 10 + key, 100 + key, 1000 + key)); + key = key + 1; + } + + let case_index: i64 = 0; + let all_stable: i32 = 1; + while (case_index < 20) { + let specs: ArrayList[ViewSpec] = make[ViewSpec](5); + let step: i64 = 1; + if ((case_index % 2) == 1) { step = 3; } + let i: i64 = 0; + while (i < 5) { + let permuted_key: i64 = (case_index + i * step) % 5; + push[ViewSpec](&specs, view_spec(node_key(permuted_key), 10 + permuted_key, 100 + permuted_key)); + i = i + 1; + } + let next_instance: i64 = 9000; + let result: ReconcileResult = reconcile(previous, specs, &next_instance); + if (result.reused != 5 || result.inserted != 0 || result.updated != 0 || result.removed != 0) { all_stable = 0; } + i = 0; + while (i < result.nodes.len) { + if (result.nodes.data[i].instance_id != 1000 + result.nodes.data[i].key.value) { all_stable = 0; } + i = i + 1; + } + reconcile_free(&result); + free[ViewSpec](&specs); + case_index = case_index + 1; + } + expect(state, all_stable, "20-permutation sweep preserves identity without phantom work"); + free[RenderNode](&previous); +} + +fn environment_contract(state: *TestState) void { + let env: Environment = environment_make(); + expect(state, environment_get(env, 1, 99) == 99, "environment miss uses its explicit fallback"); + let first: i64 = environment_set(&env, 1, 42); + let same: i64 = environment_set(&env, 1, 42); + let changed: i64 = environment_set(&env, 1, 43); + expect(state, first == 1 && same == 1 && changed == 2, "environment revisions change only when values change"); + expect(state, environment_get(env, 1, 0) == 43, "environment returns the latest value"); + environment_free(&env); +} + +fn reconciliation_contract(state: *TestState) void { + let previous: ArrayList[RenderNode] = make[RenderNode](2); + push[RenderNode](&previous, render_node(node_key(1), 10, 100, 40)); + push[RenderNode](&previous, render_node(node_key(2), 20, 200, 41)); + + let next: ArrayList[ViewSpec] = make[ViewSpec](3); + push[ViewSpec](&next, view_spec(node_key(2), 20, 200)); + push[ViewSpec](&next, view_spec(node_key(1), 10, 101)); + push[ViewSpec](&next, view_spec(node_key(3), 30, 300)); + let next_instance: i64 = 100; + let first: ReconcileResult = reconcile(previous, next, &next_instance); + + expect(state, first.reused == 2 && first.updated == 1 && first.inserted == 1 && first.removed == 0, "reconciliation reports reuse, update, and insertion exactly"); + expect(state, first.nodes.data[0].instance_id == 41 && first.nodes.data[1].instance_id == 40 && first.nodes.data[2].instance_id == 100, "stable keys preserve identity through reorder"); + expect(state, first.nodes.data[1].generation == 1, "fingerprint change advances only the updated node generation"); + + let replacement_specs: ArrayList[ViewSpec] = make[ViewSpec](2); + push[ViewSpec](&replacement_specs, view_spec(node_key(2), 99, 201)); + push[ViewSpec](&replacement_specs, view_spec(node_key(3), 30, 300)); + let second: ReconcileResult = reconcile(first.nodes, replacement_specs, &next_instance); + expect(state, second.inserted == 1 && second.reused == 1 && second.removed == 2, "kind replacement and missing keys retire old nodes"); + expect(state, second.nodes.data[0].instance_id == 101 && second.nodes.data[1].instance_id == 100, "replacement gets a new identity while retained key stays stable"); + + let duplicate_specs: ArrayList[ViewSpec] = make[ViewSpec](2); + push[ViewSpec](&duplicate_specs, view_spec(node_key(9), 1, 1)); + push[ViewSpec](&duplicate_specs, view_spec(node_key(9), 1, 2)); + let empty: ArrayList[RenderNode] = make[RenderNode](1); + let duplicate: ReconcileResult = reconcile(empty, duplicate_specs, &next_instance); + expect(state, duplicate.duplicate_keys == 1 && duplicate.nodes.len == 1, "duplicate keys fail visibly and deterministically keep the first node"); + + reconcile_free(&duplicate); + free[RenderNode](&empty); + free[ViewSpec](&duplicate_specs); + reconcile_free(&second); + free[ViewSpec](&replacement_specs); + reconcile_free(&first); + free[ViewSpec](&next); + free[RenderNode](&previous); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + state_contract(&state); + environment_contract(&state); + reconciliation_contract(&state); + reconciliation_property_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("State/reconcile contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); + _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index bc27a4e..71dbf57 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -16,5 +16,7 @@ trap cleanup EXIT cd "$root" "$znc" tests/flex_contract.zag --no-zagd --no-analyze --no-foreground-cache -o "$tmp/flex-contract" "$tmp/flex-contract" +"$znc" tests/state_reconcile_contract.zag --no-zagd --no-analyze --no-foreground-cache -o "$tmp/state-reconcile-contract" +"$tmp/state-reconcile-contract" -printf 'headless test: PASS (constraints and Flex)\n' +printf 'headless test: PASS (state, reconciliation, constraints, and Flex)\n' From d1bc318040d3352ec5e5c61393950e5dca28fb13 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 05:14:39 -0700 Subject: [PATCH 006/103] Implement owned semantics tree foundation --- CHANGELOG.md | 2 + README.md | 15 +- docs/architecture/README.md | 6 + src/semantics/semantics.zag | 291 +++++++++++++++++++++++++++++++++++ tests/semantics_contract.zag | 142 +++++++++++++++++ tools/test-headless.sh | 4 +- 6 files changed, 455 insertions(+), 5 deletions(-) create mode 100644 src/semantics/semantics.zag create mode 100644 tests/semantics_contract.zag diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fdd501..94d34c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ include a generated capability record and exact Zag compiler revision. - Experimental keyed reconciliation with stable reorder identity and fail-visible duplicate-key handling. - Deterministic fixed-point geometry, constraints, and single-line Flex. +- An experimental owned semantics tree with roles, actions, focus order, + live regions, ranges, selection, text navigation, and stable generated IDs. ## 0.1.0-experimental.0 (unreleased) diff --git a/README.md b/README.md index 15bca7b..1ee6d91 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ modern materials and asset fidelity, and a complete PrismStudio UI replacement. This repository is at **0.1.0-experimental.0**. It currently contains the accepted product contract, executable Milestone 0 checks, and the first -deterministic state, keyed reconciliation, geometry, and Flex slices. It does -not yet contain a usable renderer, window shell, component library, or -supported platform backend. Nothing in this repository is a Zagkit 1.0 release. +deterministic state, keyed reconciliation, geometry, Flex, and semantics +slices. It does not yet contain a usable renderer, window shell, component +library, or supported platform backend. Nothing in this repository is a Zagkit +1.0 release. ## What Zagkit owns @@ -40,7 +41,7 @@ is normative in [DEPENDENCIES.md](DEPENDENCIES.md). | Product and architecture contract | accepted | [RFC index](docs/rfcs/README.md) | | Compiler dependency | pinned, prerequisites incomplete | [toolchain lock](contracts/toolchain.json) | | Platform shells | unavailable | [support matrix](SUPPORT.md) | -| Headless core | experimental state dependencies, keyed reconciliation, geometry, constraints, and single-line Flex | [headless test](tools/test-headless.sh) | +| Headless core | experimental state dependencies, keyed reconciliation, geometry, Flex, and semantics | [headless test](tools/test-headless.sh) | | Components and visual language | inventory only, visual review pending | [component inventory](contracts/components.json) | | Flex and Zagkit Talkback | Flex foundation executing; wrap, grid, overlay, breakpoints, and Talkback remain | [Flex contract](tests/flex_contract.zag) | | Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | @@ -63,6 +64,12 @@ environment values, reconciliation cancellation, child ownership, replay, intrinsic measurement, wrapping, grid, overlay, and breakpoints remain open; the corresponding Milestone 2 checklist items are not complete. +The semantics slice owns copied names and values, stable keys, explicit action +capabilities, deterministic focus order, live-region state, ranges, selection, +and text-navigation bounds. Invalid parents, duplicate IDs and focus order, +malformed ranges, and malformed selections fail visibly before tree mutation. +Native accessibility adapters and the Zagkit Talkback protocol remain open. + Run the deterministic headless foundation test with: ```sh diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 5a6d371..8c6fb2c 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -34,3 +34,9 @@ preserves retained identity through reorder and fails visibly on duplicate keys. Ownership, threading, serialization, typed environment values, cancellation, and deterministic replay are still open contracts rather than stable API. + +The parallel experimental `SemanticsTree` retains owned names and values plus +roles, actions, focus order, live regions, ranges, selection, and text +navigation. Its stable `NodeKey` identity is shared with rendering, so future +accessibility adapters, semantic tests, and Zagkit Talkback automation can +query product meaning without reconstructing it from pixels. diff --git a/src/semantics/semantics.zag b/src/semantics/semantics.zag new file mode 100644 index 0000000..bdbd1be --- /dev/null +++ b/src/semantics/semantics.zag @@ -0,0 +1,291 @@ +@import("std:list") +@import("../core/view_contract.zag") + +enum SemanticRole { + application, + window, + group, + text, + button, + toggle, + checkbox, + radio, + slider, + progress, + text_field, + text_editor, + image, + link, + menu, + menu_item, + tab, + list, + list_item, + table, + row, + cell, + tree, + tree_item, + dialog, +} + +enum SemanticAction { + activate, + set_value, + increment, + decrement, + focus, + scroll, + expand, + collapse, + dismiss, + select, +} + +enum LiveRegion { off, polite, assertive } + +enum SemanticsError { + none, + duplicate_id, + missing_parent, + duplicate_focus_order, + invalid_range, + invalid_text_selection, +} + +struct SemanticsSpec { + id: NodeKey, + parent: NodeKey, + role: SemanticRole, + name: []u8, + value: []u8, + action_mask: i64, + focus_order: i64, + disabled: i32, + selected: i32, + hidden: i32, + live_region: LiveRegion, + has_range: i32, + range_min: i64, + range_max: i64, + range_value: i64, + range_step: i64, + text_length: i64, + selection_start: i64, + selection_end: i64, +} + +struct SemanticsNode { + id: NodeKey, + parent: NodeKey, + role: SemanticRole, + name: ArrayList[u8], + value: ArrayList[u8], + action_mask: i64, + focus_order: i64, + disabled: i32, + selected: i32, + hidden: i32, + live_region: LiveRegion, + has_range: i32, + range_min: i64, + range_max: i64, + range_value: i64, + range_step: i64, + text_length: i64, + selection_start: i64, + selection_end: i64, +} + +struct SemanticsTree { + nodes: ArrayList[SemanticsNode], + revision: i64, + last_error: SemanticsError, + error_node: NodeKey, +} + +fn semantic_root_key() NodeKey { return node_key(0 - 1); } + +fn semantic_action_bit(action: SemanticAction) i64 { + return switch (action) { + .activate => 1, + .set_value => 2, + .increment => 4, + .decrement => 8, + .focus => 16, + .scroll => 32, + .expand => 64, + .collapse => 128, + .dismiss => 256, + .select => 512, + }; +} + +fn semantic_actions(first: SemanticAction) i64 { + return semantic_action_bit(first); +} + +fn semantic_actions_add(mask: i64, action: SemanticAction) i64 { + return mask | semantic_action_bit(action); +} + +fn semantic_actions_has(mask: i64, action: SemanticAction) i32 { + return ((mask & semantic_action_bit(action)) != 0) as i32; +} + +fn semantic_generated_key(parent: NodeKey, role: SemanticRole, ordinal: i64) NodeKey { + let role_code: i64 = role as i64; + let value: i64 = parent.value * 1000003 + role_code * 1009 + ordinal + 17; + return node_key_scoped(value, parent.generation); +} + +fn semantics_spec(id: NodeKey, parent: NodeKey, role: SemanticRole, name: []u8) SemanticsSpec { + return SemanticsSpec{ + .id = id, + .parent = parent, + .role = role, + .name = name, + .value = "", + .action_mask = 0, + .focus_order = 0, + .disabled = 0, + .selected = 0, + .hidden = 0, + .live_region = LiveRegion.off, + .has_range = 0, + .range_min = 0, + .range_max = 0, + .range_value = 0, + .range_step = 0, + .text_length = 0, + .selection_start = 0, + .selection_end = 0, + }; +} + +fn semantics_tree_make() SemanticsTree { + return SemanticsTree{ + .nodes = make[SemanticsNode](8), + .revision = 0, + .last_error = SemanticsError.none, + .error_node = semantic_root_key(), + }; +} + +fn semantic_text_copy(text: []u8) ArrayList[u8] { + let result: ArrayList[u8] = make[u8](text.len); + let i: i32 = 0; + while (i < text.len) { + push[u8](&result, text[i]); + i = i + 1; + } + return result; +} + +fn semantic_text_equal(actual: ArrayList[u8], expected: []u8) i32 { + if (actual.len != expected.len) { return 0; } + let i: i32 = 0; + while (i < actual.len) { + if (actual.data[i] != expected[i]) { return 0; } + i = i + 1; + } + return 1; +} + +fn semantics_find_index(tree: SemanticsTree, id: NodeKey) i32 { + let i: i32 = tree.nodes.len - 1; + while (i >= 0) { + if (node_key_equal(tree.nodes.data[i].id, id) == 1) { return i; } + i = i - 1; + } + return 0 - 1; +} + +fn semantics_fail(tree: *SemanticsTree, error: SemanticsError, id: NodeKey) SemanticsError { + tree.*.last_error = error; + tree.*.error_node = id; + return error; +} + +fn semantics_add(tree: *SemanticsTree, spec: SemanticsSpec) SemanticsError { + if (semantics_find_index(tree.*, spec.id) >= 0) { + return semantics_fail(tree, SemanticsError.duplicate_id, spec.id); + } + if (node_key_equal(spec.parent, semantic_root_key()) == 0 && + semantics_find_index(tree.*, spec.parent) < 0) { + return semantics_fail(tree, SemanticsError.missing_parent, spec.id); + } + if (spec.focus_order > 0) { + let i: i32 = 0; + while (i < tree.*.nodes.len) { + if (tree.*.nodes.data[i].focus_order == spec.focus_order) { + return semantics_fail(tree, SemanticsError.duplicate_focus_order, spec.id); + } + i = i + 1; + } + } + if (spec.has_range != 0 && + (spec.range_min > spec.range_max || spec.range_value < spec.range_min || + spec.range_value > spec.range_max || spec.range_step <= 0)) { + return semantics_fail(tree, SemanticsError.invalid_range, spec.id); + } + if (spec.text_length < 0 || spec.selection_start < 0 || + spec.selection_end < spec.selection_start || spec.selection_end > spec.text_length) { + return semantics_fail(tree, SemanticsError.invalid_text_selection, spec.id); + } + + let node: SemanticsNode = SemanticsNode{ + .id = spec.id, + .parent = spec.parent, + .role = spec.role, + .name = semantic_text_copy(spec.name), + .value = semantic_text_copy(spec.value), + .action_mask = spec.action_mask, + .focus_order = spec.focus_order, + .disabled = spec.disabled, + .selected = spec.selected, + .hidden = spec.hidden, + .live_region = spec.live_region, + .has_range = spec.has_range, + .range_min = spec.range_min, + .range_max = spec.range_max, + .range_value = spec.range_value, + .range_step = spec.range_step, + .text_length = spec.text_length, + .selection_start = spec.selection_start, + .selection_end = spec.selection_end, + }; + push[SemanticsNode](&tree.*.nodes, node); + tree.*.revision = tree.*.revision + 1; + tree.*.last_error = SemanticsError.none; + tree.*.error_node = semantic_root_key(); + return SemanticsError.none; +} + +fn semantics_next_focus(tree: SemanticsTree, after_order: i64) NodeKey { + let found: i32 = 0; + let best_order: i64 = 0; + let best: NodeKey = semantic_root_key(); + let i: i32 = 0; + while (i < tree.nodes.len) { + let node: SemanticsNode = tree.nodes.data[i]; + if (node.focus_order > after_order && node.disabled == 0 && node.hidden == 0 && + (found == 0 || node.focus_order < best_order)) { + found = 1; + best_order = node.focus_order; + best = node.id; + } + i = i + 1; + } + return best; +} + +fn semantics_tree_free(tree: *SemanticsTree) void { + let i: i32 = 0; + while (i < tree.*.nodes.len) { + free[u8](&tree.*.nodes.data[i].name); + free[u8](&tree.*.nodes.data[i].value); + i = i + 1; + } + free[SemanticsNode](&tree.*.nodes); +} diff --git a/tests/semantics_contract.zag b/tests/semantics_contract.zag new file mode 100644 index 0000000..47243d3 --- /dev/null +++ b/tests/semantics_contract.zag @@ -0,0 +1,142 @@ +@import("../src/semantics/semantics.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn tree_contract(state: *TestState) void { + let tree: SemanticsTree = semantics_tree_make(); + let root_id: NodeKey = node_key(1); + let root: SemanticsSpec = semantics_spec(root_id, semantic_root_key(), SemanticRole.window, "Document"); + expect(state, semantics_add(&tree, root) == SemanticsError.none, "a semantic root is accepted"); + + let save_id: NodeKey = node_key(2); + let save: SemanticsSpec = semantics_spec(save_id, root_id, SemanticRole.button, "Save"); + save.action_mask = semantic_actions(SemanticAction.activate); + save.action_mask = semantic_actions_add(save.action_mask, SemanticAction.focus); + save.focus_order = 2; + save.selected = 1; + save.live_region = LiveRegion.polite; + expect(state, semantics_add(&tree, save) == SemanticsError.none, "a named actionable child is accepted"); + + let field_id: NodeKey = node_key(3); + let field: SemanticsSpec = semantics_spec(field_id, root_id, SemanticRole.text_field, "Part name"); + field.value = "Bracket"; + field.action_mask = semantic_actions_add(semantic_actions(SemanticAction.set_value), SemanticAction.focus); + field.focus_order = 1; + field.text_length = 7; + field.selection_start = 0; + field.selection_end = 7; + expect(state, semantics_add(&tree, field) == SemanticsError.none, "editable text navigation data is accepted"); + + let slider_id: NodeKey = node_key(4); + let slider: SemanticsSpec = semantics_spec(slider_id, root_id, SemanticRole.slider, "Zoom"); + slider.value = "100 percent"; + slider.action_mask = semantic_actions_add(semantic_actions(SemanticAction.increment), SemanticAction.decrement); + slider.has_range = 1; + slider.range_min = 25; + slider.range_max = 400; + slider.range_value = 100; + slider.range_step = 25; + expect(state, semantics_add(&tree, slider) == SemanticsError.none, "range semantics preserve value and step"); + + let save_index: i32 = semantics_find_index(tree, save_id); + let field_index: i32 = semantics_find_index(tree, field_id); + let slider_index: i32 = semantics_find_index(tree, slider_id); + expect(state, save_index >= 0 && semantic_text_equal(tree.nodes.data[save_index].name, "Save") == 1, "owned semantic names are queryable by stable ID"); + expect(state, semantic_actions_has(tree.nodes.data[save_index].action_mask, SemanticAction.activate) == 1 && semantic_actions_has(tree.nodes.data[save_index].action_mask, SemanticAction.scroll) == 0, "semantic actions are explicit capabilities"); + expect(state, tree.nodes.data[save_index].selected == 1 && tree.nodes.data[save_index].live_region == LiveRegion.polite, "selection and live-region state remain explicit"); + expect(state, field_index >= 0 && semantic_text_equal(tree.nodes.data[field_index].value, "Bracket") == 1 && tree.nodes.data[field_index].selection_end == 7, "text value and selection remain inspectable"); + expect(state, slider_index >= 0 && tree.nodes.data[slider_index].range_value == 100 && tree.nodes.data[slider_index].range_step == 25, "range value remains inspectable"); + expect(state, node_key_equal(semantics_next_focus(tree, 0), field_id) == 1 && node_key_equal(semantics_next_focus(tree, 1), save_id) == 1, "focus order is deterministic rather than insertion based"); + let disabled: SemanticsSpec = semantics_spec(node_key(5), root_id, SemanticRole.button, "Unavailable"); + disabled.action_mask = semantic_actions(SemanticAction.focus); + disabled.focus_order = 3; + disabled.disabled = 1; + _ = semantics_add(&tree, disabled); + expect(state, node_key_equal(semantics_next_focus(tree, 2), semantic_root_key()) == 1, "disabled nodes are excluded from focus traversal"); + expect(state, tree.nodes.len == 5 && tree.revision == 5, "tree revision advances exactly once per accepted node"); + semantics_tree_free(&tree); +} + +fn rejection_contract(state: *TestState) void { + let tree: SemanticsTree = semantics_tree_make(); + let root_id: NodeKey = node_key(10); + let root: SemanticsSpec = semantics_spec(root_id, semantic_root_key(), SemanticRole.window, "Root"); + _ = semantics_add(&tree, root); + + let duplicate: SemanticsSpec = semantics_spec(root_id, semantic_root_key(), SemanticRole.button, "Duplicate"); + expect(state, semantics_add(&tree, duplicate) == SemanticsError.duplicate_id && tree.nodes.len == 1, "duplicate IDs fail without mutating the tree"); + + let orphan: SemanticsSpec = semantics_spec(node_key(11), node_key(999), SemanticRole.button, "Orphan"); + expect(state, semantics_add(&tree, orphan) == SemanticsError.missing_parent && node_key_equal(tree.error_node, node_key(11)) == 1, "missing parents report the exact rejected node"); + + let first_focus: SemanticsSpec = semantics_spec(node_key(12), root_id, SemanticRole.button, "First"); + first_focus.focus_order = 1; + _ = semantics_add(&tree, first_focus); + let duplicate_focus: SemanticsSpec = semantics_spec(node_key(13), root_id, SemanticRole.button, "Second"); + duplicate_focus.focus_order = 1; + expect(state, semantics_add(&tree, duplicate_focus) == SemanticsError.duplicate_focus_order, "duplicate explicit focus order fails visibly"); + + let bad_range: SemanticsSpec = semantics_spec(node_key(14), root_id, SemanticRole.slider, "Bad range"); + bad_range.has_range = 1; + bad_range.range_min = 10; + bad_range.range_max = 5; + bad_range.range_value = 7; + bad_range.range_step = 0; + expect(state, semantics_add(&tree, bad_range) == SemanticsError.invalid_range, "malformed range semantics are rejected"); + + let bad_text: SemanticsSpec = semantics_spec(node_key(15), root_id, SemanticRole.text_field, "Bad selection"); + bad_text.text_length = 4; + bad_text.selection_start = 3; + bad_text.selection_end = 5; + expect(state, semantics_add(&tree, bad_text) == SemanticsError.invalid_text_selection, "out-of-bounds text selection is rejected"); + semantics_tree_free(&tree); +} + +fn generated_identity_contract(state: *TestState) void { + let parent: NodeKey = node_key_scoped(50, 3); + let stable: i32 = node_key_equal( + semantic_generated_key(parent, SemanticRole.button, 7), + semantic_generated_key(parent, SemanticRole.button, 7) + ); + let unique: i32 = 1; + let i: i64 = 0; + while (i < 100) { + let j: i64 = i + 1; + while (j < 100) { + if (node_key_equal( + semantic_generated_key(parent, SemanticRole.list_item, i), + semantic_generated_key(parent, SemanticRole.list_item, j) + ) == 1) { unique = 0; } + j = j + 1; + } + i = i + 1; + } + expect(state, stable == 1, "generated semantic IDs are deterministic for the same structural input"); + expect(state, unique == 1, "100 generated sibling IDs remain unique"); + expect(state, semantic_generated_key(parent, SemanticRole.button, 7).generation == 3, "generated IDs preserve parent scope generation"); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + tree_contract(&state); + rejection_contract(&state); + generated_identity_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Semantics contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); + _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 71dbf57..3411e98 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -18,5 +18,7 @@ cd "$root" "$tmp/flex-contract" "$znc" tests/state_reconcile_contract.zag --no-zagd --no-analyze --no-foreground-cache -o "$tmp/state-reconcile-contract" "$tmp/state-reconcile-contract" +"$znc" tests/semantics_contract.zag --no-zagd --no-analyze --no-foreground-cache -o "$tmp/semantics-contract" +"$tmp/semantics-contract" -printf 'headless test: PASS (state, reconciliation, constraints, and Flex)\n' +printf 'headless test: PASS (state, reconciliation, constraints, Flex, and semantics)\n' From adc3796593ffb25e78f3ca027abead409391d087 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 05:21:00 -0700 Subject: [PATCH 007/103] Establish Zagkit Talkback dispatch contract --- CHANGELOG.md | 2 + README.md | 10 +- contracts/talkback-protocol.json | 83 +++++++++ docs/automation/talkback.md | 46 +++++ src/automation/talkback.zag | 298 +++++++++++++++++++++++++++++++ tests/talkback_contract.zag | 116 ++++++++++++ tools/check-contracts.sh | 25 ++- tools/test-headless.sh | 4 +- 8 files changed, 581 insertions(+), 3 deletions(-) create mode 100644 contracts/talkback-protocol.json create mode 100644 docs/automation/talkback.md create mode 100644 src/automation/talkback.zag create mode 100644 tests/talkback_contract.zag diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d34c4..c8e40e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ include a generated capability record and exact Zag compiler revision. - Deterministic fixed-point geometry, constraints, and single-line Flex. - An experimental owned semantics tree with roles, actions, focus order, live regions, ranges, selection, text navigation, and stable generated IDs. +- The first in-process Zagkit Talkback dispatcher and machine-readable protocol + contract with ID-first targeting and explicit scale-aware pixel fallback. ## 0.1.0-experimental.0 (unreleased) diff --git a/README.md b/README.md index 1ee6d91..a592ba9 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ is normative in [DEPENDENCIES.md](DEPENDENCIES.md). | Platform shells | unavailable | [support matrix](SUPPORT.md) | | Headless core | experimental state dependencies, keyed reconciliation, geometry, Flex, and semantics | [headless test](tools/test-headless.sh) | | Components and visual language | inventory only, visual review pending | [component inventory](contracts/components.json) | -| Flex and Zagkit Talkback | Flex foundation executing; wrap, grid, overlay, breakpoints, and Talkback remain | [Flex contract](tests/flex_contract.zag) | +| Flex and Zagkit Talkback | Flex foundation and in-process ID-first Talkback dispatch executing; native transport remains unavailable | [Talkback contract](docs/automation/talkback.md) | | Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | Run the repository contract gate with: @@ -70,6 +70,14 @@ and text-navigation bounds. Invalid parents, duplicate IDs and focus order, malformed ranges, and malformed selections fail visibly before tree mutation. Native accessibility adapters and the Zagkit Talkback protocol remain open. +The first in-process Zagkit Talkback dispatcher now resolves queries and emits +validated actions against those semantic IDs, rejects stale revisions and +unavailable actions, keeps pixel fallback disabled unless explicitly +advertised, applies recorded display scale to pixel bounds, and logs accepted +and rejected requests in one ordered stream. +It is not yet a native automation transport; the exact available and unavailable +surface is documented in [the protocol contract](docs/automation/talkback.md). + Run the deterministic headless foundation test with: ```sh diff --git a/contracts/talkback-protocol.json b/contracts/talkback-protocol.json new file mode 100644 index 0000000..f084d5b --- /dev/null +++ b/contracts/talkback-protocol.json @@ -0,0 +1,83 @@ +{ + "name": "Zagkit Talkback", + "version": "0.1.0-experimental", + "android_assistive_technology": "Android TalkBack", + "transport": "not-yet-available", + "targeting": { + "primary": "semantic-id", + "id_scope": ["application", "window", "retained-node", "generation"], + "pixel_fallback": { + "state": "experimental", + "capability_gated": true, + "record_coordinates": true, + "record_scale": true, + "may_report_as_id_target": false + } + }, + "commands": [ + "discover", + "query", + "click", + "type", + "key", + "focus", + "scroll", + "drag", + "gesture", + "wait", + "assert", + "screenshot", + "timeline", + "capability-report", + "snapshot", + "replay" + ], + "statuses": [ + "accepted", + "invalid-request", + "invalid-timeout", + "stale-semantics", + "target-not-found", + "action-unavailable", + "pixel-fallback-disabled", + "pixel-out-of-bounds", + "unsupported-command" + ], + "request_fields": [ + "request-id", + "command", + "target-kind", + "target-id", + "semantics-revision", + "timeout-ms", + "pixel-x", + "pixel-y", + "scale-numerator", + "scale-denominator" + ], + "response_fields": [ + "request-id", + "status", + "target-kind", + "target-id", + "semantics-revision", + "event-sequence", + "resolved-node-index", + "result-count", + "emitted-action" + ], + "event_contract": { + "ordered": true, + "records_accepted_and_rejected": true, + "retains_target_kind": true, + "retains_pixel_scale": true + }, + "current_runtime_truth": { + "semantic-query": "experimental", + "id-actions": "experimental-event-emission", + "pixel-fallback": "experimental-disabled-by-default", + "screenshots": "unavailable", + "replay": "unavailable", + "native-transport": "unavailable" + } +} diff --git a/docs/automation/talkback.md b/docs/automation/talkback.md new file mode 100644 index 0000000..db6a024 --- /dev/null +++ b/docs/automation/talkback.md @@ -0,0 +1,46 @@ +# Zagkit Talkback protocol + +Zagkit Talkback is Zagkit's native automation and inspection control plane. It +is distinct from Android TalkBack, which remains a required assistive-technology +test target. The protocol is experimental and has no native transport yet. + +## Target truth + +Normal automation uses the exact `NodeKey` published by the retained semantics +tree. A request may pin the semantics revision; a mismatch returns +`stale-semantics` instead of risking an action on a replacement node. Missing, +hidden, disabled, and semantically unsupported targets fail explicitly. + +Pixel coordinates are a separate target kind, disabled by default. A backend +must advertise the fallback before dispatch. Pixel requests record physical +coordinates and a positive rational display scale. Zagkit converts them to its +26.6 fixed-point logical viewport for bounds checks. Every accepted or rejected +pixel attempt remains labelled `pixel`; it can never be reported as an ID +action. + +## Requests and evidence + +The machine-readable field, command, status, and capability vocabulary lives in +[the protocol contract](../../contracts/talkback-protocol.json). Request IDs +must be positive. Timeouts must be between 1 and 300000 milliseconds. Every +dispatch receives one monotonic event sequence and records the command, target +kind, target ID, semantic revision, status, coordinates, and scale. + +The first in-process slice resolves semantic discovery and queries and validates +click, type, focus, and scroll before emitting them into the ordered event +stream. Responses distinguish read-only results from emitted actions and name +the resolved semantic-node index. Capability reports and timeline counts are +available. Key, drag, gesture, wait, assertion payloads, screenshots, snapshots, +replay, native action consumption, and native transport remain unavailable and +fail closed. Advertising a command in the protocol vocabulary does not claim +its runtime capability. + +## Planned agent contract + +The complete control plane will add app and window discovery, structured query +results, text payloads, keyboard input, scrolling parameters, paths and +velocity for drag and gesture, waits and assertions, screenshots, frame +timelines, capability records, snapshots, and deterministic replay. Failed +actions will retain candidates, geometry, semantics, backend truth, and timeout +evidence in one inspectable bundle. These remain unchecked in the master goal +until their executable exit suites pass. diff --git a/src/automation/talkback.zag b/src/automation/talkback.zag new file mode 100644 index 0000000..a9c3516 --- /dev/null +++ b/src/automation/talkback.zag @@ -0,0 +1,298 @@ +@import("std:list") +@import("../core/geometry.zag") +@import("../semantics/semantics.zag") + +enum TalkbackTargetKind { semantic_id, pixel } + +enum TalkbackCommand { + discover, + query, + click, + type_text, + key, + focus, + scroll, + drag, + gesture, + wait_for, + assert_value, + screenshot, + timeline, + capability_report, + snapshot, + replay, +} + +enum TalkbackStatus { + accepted, + invalid_request, + invalid_timeout, + stale_semantics, + target_not_found, + action_unavailable, + pixel_fallback_disabled, + pixel_out_of_bounds, + unsupported_command, +} + +struct TalkbackCapabilities { + protocol_major: i64, + protocol_minor: i64, + semantic_query: i32, + id_actions: i32, + pixel_fallback: i32, + screenshots: i32, + replay: i32, +} + +struct TalkbackRequest { + request_id: i64, + command: TalkbackCommand, + target_kind: TalkbackTargetKind, + target_id: NodeKey, + semantics_revision: i64, + timeout_ms: i64, + pixel_x: i64, + pixel_y: i64, + scale_numerator: i64, + scale_denominator: i64, +} + +struct TalkbackResponse { + request_id: i64, + status: TalkbackStatus, + target_kind: TalkbackTargetKind, + target_id: NodeKey, + semantics_revision: i64, + event_sequence: i64, + resolved_node_index: i32, + result_count: i32, + emitted_action: i32, +} + +struct TalkbackEvent { + sequence: i64, + request_id: i64, + command: TalkbackCommand, + target_kind: TalkbackTargetKind, + target_id: NodeKey, + status: TalkbackStatus, + semantics_revision: i64, + pixel_x: i64, + pixel_y: i64, + scale_numerator: i64, + scale_denominator: i64, +} + +struct TalkbackSession { + capabilities: TalkbackCapabilities, + events: ArrayList[TalkbackEvent], + next_sequence: i64, +} + +fn talkback_capabilities() TalkbackCapabilities { + return TalkbackCapabilities{ + .protocol_major = 0, + .protocol_minor = 1, + .semantic_query = 1, + .id_actions = 1, + .pixel_fallback = 0, + .screenshots = 0, + .replay = 0, + }; +} + +fn talkback_session_make(capabilities: TalkbackCapabilities) TalkbackSession { + return TalkbackSession{ + .capabilities = capabilities, + .events = make[TalkbackEvent](16), + .next_sequence = 1, + }; +} + +fn talkback_session_free(session: *TalkbackSession) void { + free[TalkbackEvent](&session.*.events); +} + +fn talkback_request(request_id: i64, command: TalkbackCommand, target_id: NodeKey, semantics_revision: i64) TalkbackRequest { + return TalkbackRequest{ + .request_id = request_id, + .command = command, + .target_kind = TalkbackTargetKind.semantic_id, + .target_id = target_id, + .semantics_revision = semantics_revision, + .timeout_ms = 1000, + .pixel_x = 0, + .pixel_y = 0, + .scale_numerator = 1, + .scale_denominator = 1, + }; +} + +fn talkback_pixel_request(request_id: i64, command: TalkbackCommand, pixel_x: i64, pixel_y: i64, scale_numerator: i64, scale_denominator: i64) TalkbackRequest { + let request: TalkbackRequest = talkback_request(request_id, command, semantic_root_key(), 0 - 1); + request.target_kind = TalkbackTargetKind.pixel; + request.pixel_x = pixel_x; + request.pixel_y = pixel_y; + request.scale_numerator = scale_numerator; + request.scale_denominator = scale_denominator; + return request; +} + +fn talkback_command_has_target(command: TalkbackCommand) i32 { + return switch (command) { + .discover => 0, + .query => 1, + .click => 1, + .type_text => 1, + .key => 1, + .focus => 1, + .scroll => 1, + .drag => 1, + .gesture => 1, + .wait_for => 1, + .assert_value => 1, + .screenshot => 0, + .timeline => 0, + .capability_report => 0, + .snapshot => 0, + .replay => 0, + }; +} + +fn talkback_action_supported(capabilities: TalkbackCapabilities, target_kind: TalkbackTargetKind, pixel_action: i32) i32 { + if (target_kind == TalkbackTargetKind.pixel) { + if (pixel_action == 0) { return 0; } + return capabilities.pixel_fallback; + } + return capabilities.id_actions; +} + +fn talkback_command_supported(capabilities: TalkbackCapabilities, command: TalkbackCommand, target_kind: TalkbackTargetKind) i32 { + return switch (command) { + .discover => capabilities.semantic_query, + .query => capabilities.semantic_query, + .click => talkback_action_supported(capabilities, target_kind, 1), + .type_text => talkback_action_supported(capabilities, target_kind, 0), + .key => 0, + .focus => talkback_action_supported(capabilities, target_kind, 0), + .scroll => talkback_action_supported(capabilities, target_kind, 1), + .drag => 0, + .gesture => 0, + .wait_for => 0, + .assert_value => 0, + .screenshot => capabilities.screenshots, + .timeline => 1, + .capability_report => 1, + .snapshot => 0, + .replay => capabilities.replay, + }; +} + +fn talkback_required_action(command: TalkbackCommand) i64 { + return switch (command) { + .click => semantic_action_bit(SemanticAction.activate), + .type_text => semantic_action_bit(SemanticAction.set_value), + .focus => semantic_action_bit(SemanticAction.focus), + .scroll => semantic_action_bit(SemanticAction.scroll), + .discover => 0, + .query => 0, + .key => 0, + .drag => 0, + .gesture => 0, + .wait_for => 0, + .assert_value => 0, + .screenshot => 0, + .timeline => 0, + .capability_report => 0, + .snapshot => 0, + .replay => 0, + }; +} + +fn talkback_record(session: *TalkbackSession, tree_revision: i64, request: TalkbackRequest, status: TalkbackStatus) TalkbackResponse { + let sequence: i64 = session.*.next_sequence; + session.*.next_sequence = session.*.next_sequence + 1; + push[TalkbackEvent](&session.*.events, TalkbackEvent{ + .sequence = sequence, + .request_id = request.request_id, + .command = request.command, + .target_kind = request.target_kind, + .target_id = request.target_id, + .status = status, + .semantics_revision = tree_revision, + .pixel_x = request.pixel_x, + .pixel_y = request.pixel_y, + .scale_numerator = request.scale_numerator, + .scale_denominator = request.scale_denominator, + }); + return TalkbackResponse{ + .request_id = request.request_id, + .status = status, + .target_kind = request.target_kind, + .target_id = request.target_id, + .semantics_revision = tree_revision, + .event_sequence = sequence, + .resolved_node_index = 0 - 1, + .result_count = 0, + .emitted_action = 0, + }; +} + +fn talkback_dispatch(session: *TalkbackSession, tree: SemanticsTree, viewport: Rect, request: TalkbackRequest) TalkbackResponse { + if (request.request_id <= 0) { + return talkback_record(session, tree.revision, request, TalkbackStatus.invalid_request); + } + if (request.timeout_ms <= 0 || request.timeout_ms > 300000) { + return talkback_record(session, tree.revision, request, TalkbackStatus.invalid_timeout); + } + if (talkback_command_has_target(request.command) != 0 && + request.target_kind == TalkbackTargetKind.pixel && + session.*.capabilities.pixel_fallback == 0) { + return talkback_record(session, tree.revision, request, TalkbackStatus.pixel_fallback_disabled); + } + if (talkback_command_supported(session.*.capabilities, request.command, request.target_kind) == 0) { + return talkback_record(session, tree.revision, request, TalkbackStatus.unsupported_command); + } + if (talkback_command_has_target(request.command) == 0) { + let response: TalkbackResponse = talkback_record(session, tree.revision, request, TalkbackStatus.accepted); + if (request.command == TalkbackCommand.discover) { response.result_count = tree.nodes.len; } + if (request.command == TalkbackCommand.timeline) { response.result_count = session.*.events.len; } + if (request.command == TalkbackCommand.capability_report) { response.result_count = 1; } + return response; + } + + if (request.target_kind == TalkbackTargetKind.pixel) { + if (request.scale_numerator <= 0 || request.scale_numerator > 16 || + request.scale_denominator <= 0 || request.scale_denominator > 16 || + request.pixel_x < 0 || request.pixel_y < 0) { + return talkback_record(session, tree.revision, request, TalkbackStatus.invalid_request); + } + let logical_x: i64 = (request.pixel_x * unit_scale() * request.scale_denominator) / request.scale_numerator; + let logical_y: i64 = (request.pixel_y * unit_scale() * request.scale_denominator) / request.scale_numerator; + if (rect_contains(viewport, logical_x, logical_y) == 0) { + return talkback_record(session, tree.revision, request, TalkbackStatus.pixel_out_of_bounds); + } + let response: TalkbackResponse = talkback_record(session, tree.revision, request, TalkbackStatus.accepted); + response.emitted_action = 1; + return response; + } + + if (request.semantics_revision >= 0 && request.semantics_revision != tree.revision) { + return talkback_record(session, tree.revision, request, TalkbackStatus.stale_semantics); + } + let node_index: i32 = semantics_find_index(tree, request.target_id); + if (node_index < 0 || tree.nodes.data[node_index].hidden != 0) { + return talkback_record(session, tree.revision, request, TalkbackStatus.target_not_found); + } + let required_action: i64 = talkback_required_action(request.command); + if (tree.nodes.data[node_index].disabled != 0 || + (required_action != 0 && (tree.nodes.data[node_index].action_mask & required_action) == 0)) { + return talkback_record(session, tree.revision, request, TalkbackStatus.action_unavailable); + } + let response: TalkbackResponse = talkback_record(session, tree.revision, request, TalkbackStatus.accepted); + response.resolved_node_index = node_index; + response.result_count = 1; + if (required_action != 0) { response.emitted_action = 1; } + return response; +} diff --git a/tests/talkback_contract.zag b/tests/talkback_contract.zag new file mode 100644 index 0000000..88ee172 --- /dev/null +++ b/tests/talkback_contract.zag @@ -0,0 +1,116 @@ +@import("../src/automation/talkback.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn make_tree() SemanticsTree { + let tree: SemanticsTree = semantics_tree_make(); + let root: SemanticsSpec = semantics_spec(node_key(1), semantic_root_key(), SemanticRole.window, "Window"); + _ = semantics_add(&tree, root); + let button: SemanticsSpec = semantics_spec(node_key(2), node_key(1), SemanticRole.button, "Create part"); + button.action_mask = semantic_actions_add(semantic_actions(SemanticAction.activate), SemanticAction.focus); + _ = semantics_add(&tree, button); + let field: SemanticsSpec = semantics_spec(node_key(3), node_key(1), SemanticRole.text_field, "Part name"); + field.action_mask = semantic_actions_add(semantic_actions(SemanticAction.set_value), SemanticAction.focus); + _ = semantics_add(&tree, field); + return tree; +} + +fn id_target_contract(state: *TestState) void { + let tree: SemanticsTree = make_tree(); + let capabilities: TalkbackCapabilities = talkback_capabilities(); + let session: TalkbackSession = talkback_session_make(capabilities); + let viewport: Rect = rect(0, 0, 800 * unit_scale(), 600 * unit_scale()); + + let query: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(1, TalkbackCommand.query, node_key(2), tree.revision)); + let click: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(2, TalkbackCommand.click, node_key(2), tree.revision)); + let type_field: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(3, TalkbackCommand.type_text, node_key(3), tree.revision)); + let type_button: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(4, TalkbackCommand.type_text, node_key(2), tree.revision)); + expect(state, query.status == TalkbackStatus.accepted && click.status == TalkbackStatus.accepted && type_field.status == TalkbackStatus.accepted, "ID queries and declared semantic actions are accepted"); + expect(state, query.resolved_node_index == semantics_find_index(tree, node_key(2)) && query.result_count == 1, "ID queries resolve to an inspectable semantic node"); + expect(state, click.emitted_action == 1 && query.emitted_action == 0, "responses distinguish emitted actions from read-only queries"); + expect(state, type_button.status == TalkbackStatus.action_unavailable, "an ID action absent from semantics fails visibly"); + + let stale: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(5, TalkbackCommand.click, node_key(2), tree.revision - 1)); + let missing: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(6, TalkbackCommand.click, node_key(99), tree.revision)); + expect(state, stale.status == TalkbackStatus.stale_semantics, "stale semantic revisions cannot silently target replacement nodes"); + expect(state, missing.status == TalkbackStatus.target_not_found, "missing ID targets return a structured failure"); + + let unsupported: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(7, TalkbackCommand.drag, node_key(2), tree.revision)); + let bad_timeout_request: TalkbackRequest = talkback_request(8, TalkbackCommand.query, node_key(2), tree.revision); + bad_timeout_request.timeout_ms = 0; + let bad_timeout: TalkbackResponse = talkback_dispatch(&session, tree, viewport, bad_timeout_request); + expect(state, unsupported.status == TalkbackStatus.unsupported_command, "unimplemented commands remain fail-closed in capability truth"); + expect(state, bad_timeout.status == TalkbackStatus.invalid_timeout, "invalid timeout policy is rejected before dispatch"); + expect(state, session.events.len == 8 && session.events.data[7].sequence == 8, "accepted and rejected requests share one ordered evidence log"); + + talkback_session_free(&session); + semantics_tree_free(&tree); +} + +fn pixel_fallback_contract(state: *TestState) void { + let tree: SemanticsTree = make_tree(); + let capabilities: TalkbackCapabilities = talkback_capabilities(); + let session: TalkbackSession = talkback_session_make(capabilities); + let viewport: Rect = rect(0, 0, 100 * unit_scale(), 80 * unit_scale()); + + let denied_request: TalkbackRequest = talkback_pixel_request(20, TalkbackCommand.click, 20, 20, 2, 1); + let denied: TalkbackResponse = talkback_dispatch(&session, tree, viewport, denied_request); + expect(state, denied.status == TalkbackStatus.pixel_fallback_disabled, "pixel fallback is disabled unless the backend advertises it"); + expect(state, denied.target_kind == TalkbackTargetKind.pixel && session.events.data[0].target_kind == TalkbackTargetKind.pixel, "pixel attempts are never reported as ID targeting"); + + session.capabilities.pixel_fallback = 1; + session.capabilities.id_actions = 0; + let accepted_request: TalkbackRequest = talkback_pixel_request(21, TalkbackCommand.click, 100, 80, 2, 1); + let accepted: TalkbackResponse = talkback_dispatch(&session, tree, viewport, accepted_request); + let outside_request: TalkbackRequest = talkback_pixel_request(22, TalkbackCommand.click, 201, 40, 2, 1); + let outside: TalkbackResponse = talkback_dispatch(&session, tree, viewport, outside_request); + expect(state, accepted.status == TalkbackStatus.accepted, "pixel fallback resolves physical pixels through recorded display scale"); + expect(state, accepted.emitted_action == 1, "pixel action emission is independent from ID-action capability"); + expect(state, outside.status == TalkbackStatus.pixel_out_of_bounds, "scaled pixel targets outside the viewport fail visibly"); + expect(state, session.events.data[1].scale_numerator == 2 && session.events.data[1].scale_denominator == 1 && session.events.data[1].pixel_x == 100, "pixel evidence retains coordinates and scale"); + + talkback_session_free(&session); + semantics_tree_free(&tree); +} + +fn capability_contract(state: *TestState) void { + let tree: SemanticsTree = make_tree(); + let capabilities: TalkbackCapabilities = talkback_capabilities(); + let session: TalkbackSession = talkback_session_make(capabilities); + let viewport: Rect = rect(0, 0, 1, 1); + let report: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(30, TalkbackCommand.capability_report, semantic_root_key(), 0 - 1)); + let screenshot: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(31, TalkbackCommand.screenshot, semantic_root_key(), 0 - 1)); + let replay: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(32, TalkbackCommand.replay, semantic_root_key(), 0 - 1)); + let assertion: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(33, TalkbackCommand.assert_value, node_key(2), tree.revision)); + expect(state, report.status == TalkbackStatus.accepted, "capability reporting is always queryable"); + expect(state, screenshot.status == TalkbackStatus.unsupported_command && replay.status == TalkbackStatus.unsupported_command, "unavailable screenshot and replay capabilities fail closed"); + expect(state, assertion.status == TalkbackStatus.unsupported_command, "assertions remain unavailable until payload comparison is implemented"); + expect(state, capabilities.protocol_major == 0 && capabilities.protocol_minor == 1, "the experimental protocol version is explicit"); + talkback_session_free(&session); + semantics_tree_free(&tree); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + id_target_contract(&state); + pixel_fallback_contract(&state); + capability_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Zagkit Talkback contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); + _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/check-contracts.sh b/tools/check-contracts.sh index 29b5c5b..605cc8d 100755 --- a/tools/check-contracts.sh +++ b/tools/check-contracts.sh @@ -24,6 +24,7 @@ for path in \ LICENSE README.md GOAL.md CHANGELOG.md CONTRIBUTING.md GOVERNANCE.md VERSIONING.md \ SUPPORT.md SECURITY.md DEPENDENCIES.md ROADMAP.md zag.mod \ docs/design/visual-direction.md docs/quality/release-gates.md \ + docs/automation/talkback.md \ docs/architecture/README.md docs/milestones/0000-product-contract.md \ benchmarks/README.md; do require_file "$path" @@ -61,7 +62,7 @@ done for path in contracts/toolchain.json contracts/platforms.json \ contracts/upstream-zag.json contracts/components.json \ - contracts/benchmark-scenes.json; do + contracts/benchmark-scenes.json contracts/talkback-protocol.json; do require_json "$path" done @@ -179,6 +180,28 @@ jq -e ' (.global_performance_contract.regression_without_reviewed_waiver_percent_max == 5.0) ' contracts/benchmark-scenes.json >/dev/null || fail "benchmark scene contract is incomplete" +jq -e ' + .name == "Zagkit Talkback" and + .android_assistive_technology == "Android TalkBack" and + (.version | test("^[0-9]+\\.[0-9]+\\.[0-9]+-experimental$")) and + .targeting.primary == "semantic-id" and + .targeting.pixel_fallback.capability_gated == true and + .targeting.pixel_fallback.record_coordinates == true and + .targeting.pixel_fallback.record_scale == true and + .targeting.pixel_fallback.may_report_as_id_target == false and + (.commands | length == 16) and + ((.commands | unique | length) == (.commands | length)) and + (.statuses | length >= 9) and + ((.statuses | unique | length) == (.statuses | length)) and + (.request_fields | index("target-kind") != null) and + (.request_fields | index("semantics-revision") != null) and + (.response_fields | index("event-sequence") != null) and + (.response_fields | index("emitted-action") != null) and + .event_contract.ordered == true and + .event_contract.records_accepted_and_rejected == true and + .current_runtime_truth."native-transport" == "unavailable" +' contracts/talkback-protocol.json >/dev/null || fail "Zagkit Talkback protocol contract is incomplete" + if [ -d ../zag/.git ]; then git -C ../zag cat-file -e "$compiler_commit^{commit}" 2>/dev/null \ || fail "neighboring Zag repository does not contain the pinned commit" diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 3411e98..d9df64f 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -20,5 +20,7 @@ cd "$root" "$tmp/state-reconcile-contract" "$znc" tests/semantics_contract.zag --no-zagd --no-analyze --no-foreground-cache -o "$tmp/semantics-contract" "$tmp/semantics-contract" +"$znc" tests/talkback_contract.zag --no-zagd --no-analyze --no-foreground-cache -o "$tmp/talkback-contract" +"$tmp/talkback-contract" -printf 'headless test: PASS (state, reconciliation, constraints, Flex, and semantics)\n' +printf 'headless test: PASS (state, reconciliation, constraints, Flex, semantics, and Zagkit Talkback)\n' From 2dbde13615b42e0b51b330106c21f11b92d621c0 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 05:24:19 -0700 Subject: [PATCH 008/103] Implement immutable display list foundation --- CHANGELOG.md | 2 + README.md | 14 +- docs/architecture/README.md | 7 + src/render/display_list.zag | 243 ++++++++++++++++++++++++++++++++ tests/display_list_contract.zag | 141 ++++++++++++++++++ tools/test-headless.sh | 4 +- 6 files changed, 407 insertions(+), 4 deletions(-) create mode 100644 src/render/display_list.zag create mode 100644 tests/display_list_contract.zag diff --git a/CHANGELOG.md b/CHANGELOG.md index c8e40e3..8e3e38f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ include a generated capability record and exact Zag compiler revision. live regions, ranges, selection, text navigation, and stable generated IDs. - The first in-process Zagkit Talkback dispatcher and machine-readable protocol contract with ID-first targeting and explicit scale-aware pixel fallback. +- An experimental immutable display list for explicit paints, resources, clips, + transforms, layers, and effects with deterministic content identity. ## 0.1.0-experimental.0 (unreleased) diff --git a/README.md b/README.md index a592ba9..ac180fb 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ modern materials and asset fidelity, and a complete PrismStudio UI replacement. This repository is at **0.1.0-experimental.0**. It currently contains the accepted product contract, executable Milestone 0 checks, and the first -deterministic state, keyed reconciliation, geometry, Flex, and semantics -slices. It does not yet contain a usable renderer, window shell, component +deterministic state, keyed reconciliation, geometry, Flex, semantics, Talkback, +and display-list slices. It does not yet contain a usable renderer, window shell, component library, or supported platform backend. Nothing in this repository is a Zagkit 1.0 release. @@ -41,7 +41,7 @@ is normative in [DEPENDENCIES.md](DEPENDENCIES.md). | Product and architecture contract | accepted | [RFC index](docs/rfcs/README.md) | | Compiler dependency | pinned, prerequisites incomplete | [toolchain lock](contracts/toolchain.json) | | Platform shells | unavailable | [support matrix](SUPPORT.md) | -| Headless core | experimental state dependencies, keyed reconciliation, geometry, Flex, and semantics | [headless test](tools/test-headless.sh) | +| Headless core | experimental state, reconciliation, geometry, Flex, semantics, Talkback, and display lists | [headless test](tools/test-headless.sh) | | Components and visual language | inventory only, visual review pending | [component inventory](contracts/components.json) | | Flex and Zagkit Talkback | Flex foundation and in-process ID-first Talkback dispatch executing; native transport remains unavailable | [Talkback contract](docs/automation/talkback.md) | | Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | @@ -78,6 +78,14 @@ and rejected requests in one ordered stream. It is not yet a native automation transport; the exact available and unavailable surface is documented in [the protocol contract](docs/automation/talkback.md). +The immutable display-list slice records retained ownership, clips, transforms, +fixed-point geometry, RGBA16 paints, paths, images, glyph runs, layers, and +effects as explicit operations. Invalid geometry, resources, parameters, and +stack balance fail before mutation; the builder rejects writes after sealing, +and verification detects out-of-contract raw mutation against deterministic +content identity. Path/resource storage, serialization, damage, CPU +rasterization, and GPU transport remain open. + Run the deterministic headless foundation test with: ```sh diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 8c6fb2c..198a29d 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -40,3 +40,10 @@ roles, actions, focus order, live regions, ranges, selection, and text navigation. Its stable `NodeKey` identity is shared with rendering, so future accessibility adapters, semantic tests, and Zagkit Talkback automation can query product meaning without reconstructing it from pixels. + +Rendering begins with an experimental immutable `DisplayList`. Every operation +retains its owning `NodeKey`; paths, images, glyph runs, clips, transforms, +RGBA16 paints, layers, and effects remain explicit rather than backend calls. +Balanced lists seal with deterministic content identity, and verification +detects mutation outside the builder contract before rendering. Resource +storage, serialization, damage, the CPU oracle, and GPU transports remain open. diff --git a/src/render/display_list.zag b/src/render/display_list.zag new file mode 100644 index 0000000..7d12995 --- /dev/null +++ b/src/render/display_list.zag @@ -0,0 +1,243 @@ +@import("std:list") +@import("../core/geometry.zag") +@import("../core/view_contract.zag") + +enum DisplayOpKind { + save, + restore, + clip_rect, + concat_transform, + fill_rect, + stroke_rect, + draw_path, + draw_image, + draw_glyph_run, + begin_layer, + end_layer, + apply_effect, +} + +enum DisplayError { + none, + not_sealed, + sealed, + content_mutated, + invalid_geometry, + invalid_resource, + invalid_parameter, + stack_underflow, + layer_underflow, + unbalanced_stack, + unbalanced_layers, +} + +struct Paint { + red: i64, + green: i64, + blue: i64, + alpha: i64, + stroke_width: i64, +} + +struct Transform { + scale_x: i64, + skew_x: i64, + skew_y: i64, + scale_y: i64, + translate_x: i64, + translate_y: i64, +} + +struct DisplayOp { + kind: DisplayOpKind, + owner: NodeKey, + bounds: Rect, + paint: Paint, + transform: Transform, + resource_id: i64, + parameter: i64, +} + +struct DisplayList { + ops: ArrayList[DisplayOp], + sealed: i32, + stack_depth: i32, + layer_depth: i32, + revision: i64, + content_hash: i64, + last_error: DisplayError, + error_index: i32, +} + +fn display_color_channel(value: i64) i64 { + if (value < 0) { return 0; } + if (value > 65535) { return 65535; } + return value; +} + +fn paint_rgba16(red: i64, green: i64, blue: i64, alpha: i64) Paint { + return Paint{ + .red = display_color_channel(red), + .green = display_color_channel(green), + .blue = display_color_channel(blue), + .alpha = display_color_channel(alpha), + .stroke_width = unit_scale(), + }; +} + +fn transform_identity() Transform { + return Transform{ + .scale_x = unit_scale(), + .skew_x = 0, + .skew_y = 0, + .scale_y = unit_scale(), + .translate_x = 0, + .translate_y = 0, + }; +} + +fn display_op(kind: DisplayOpKind, owner: NodeKey) DisplayOp { + return DisplayOp{ + .kind = kind, + .owner = owner, + .bounds = rect(0, 0, 0, 0), + .paint = paint_rgba16(0, 0, 0, 65535), + .transform = transform_identity(), + .resource_id = 0, + .parameter = 0, + }; +} + +fn display_list_make() DisplayList { + return DisplayList{ + .ops = make[DisplayOp](32), + .sealed = 0, + .stack_depth = 0, + .layer_depth = 0, + .revision = 0, + .content_hash = 17, + .last_error = DisplayError.none, + .error_index = 0 - 1, + }; +} + +fn display_list_free(list: *DisplayList) void { + free[DisplayOp](&list.*.ops); +} + +fn display_mix(hash: i64, value: i64) i64 { + let modulus: i64 = 2147483647; + let component: i64 = value % modulus; + if (component < 0) { component = component + modulus; } + return (hash * 131 + component) % modulus; +} + +fn display_hash_op(hash: i64, op: DisplayOp) i64 { + let result: i64 = display_mix(hash, op.kind as i64); + result = display_mix(result, op.owner.value); + result = display_mix(result, op.owner.generation); + result = display_mix(result, op.bounds.x); + result = display_mix(result, op.bounds.y); + result = display_mix(result, op.bounds.width); + result = display_mix(result, op.bounds.height); + result = display_mix(result, op.paint.red); + result = display_mix(result, op.paint.green); + result = display_mix(result, op.paint.blue); + result = display_mix(result, op.paint.alpha); + result = display_mix(result, op.paint.stroke_width); + result = display_mix(result, op.transform.scale_x); + result = display_mix(result, op.transform.skew_x); + result = display_mix(result, op.transform.skew_y); + result = display_mix(result, op.transform.scale_y); + result = display_mix(result, op.transform.translate_x); + result = display_mix(result, op.transform.translate_y); + result = display_mix(result, op.resource_id); + return display_mix(result, op.parameter); +} + +fn display_fail(list: *DisplayList, error: DisplayError) DisplayError { + list.*.last_error = error; + list.*.error_index = list.*.ops.len; + return error; +} + +fn display_op_requires_bounds(kind: DisplayOpKind) i32 { + return (kind == DisplayOpKind.clip_rect || kind == DisplayOpKind.fill_rect || + kind == DisplayOpKind.stroke_rect || kind == DisplayOpKind.draw_image || + kind == DisplayOpKind.begin_layer) as i32; +} + +fn display_op_requires_resource(kind: DisplayOpKind) i32 { + return (kind == DisplayOpKind.draw_path || kind == DisplayOpKind.draw_image || + kind == DisplayOpKind.draw_glyph_run || kind == DisplayOpKind.apply_effect) as i32; +} + +fn display_list_push(list: *DisplayList, op: DisplayOp) DisplayError { + if (list.*.sealed != 0) { return display_fail(list, DisplayError.sealed); } + if (display_op_requires_bounds(op.kind) != 0 && + (op.bounds.width <= 0 || op.bounds.height <= 0)) { + return display_fail(list, DisplayError.invalid_geometry); + } + if (display_op_requires_resource(op.kind) != 0 && op.resource_id <= 0) { + return display_fail(list, DisplayError.invalid_resource); + } + if (op.paint.stroke_width < 0) { return display_fail(list, DisplayError.invalid_parameter); } + if (op.kind == DisplayOpKind.begin_layer && (op.parameter < 0 || op.parameter > 65535)) { + return display_fail(list, DisplayError.invalid_parameter); + } + if (op.kind == DisplayOpKind.restore && list.*.stack_depth <= 0) { + return display_fail(list, DisplayError.stack_underflow); + } + if (op.kind == DisplayOpKind.end_layer && list.*.layer_depth <= 0) { + return display_fail(list, DisplayError.layer_underflow); + } + + push[DisplayOp](&list.*.ops, op); + if (op.kind == DisplayOpKind.save) { list.*.stack_depth = list.*.stack_depth + 1; } + if (op.kind == DisplayOpKind.restore) { list.*.stack_depth = list.*.stack_depth - 1; } + if (op.kind == DisplayOpKind.begin_layer) { list.*.layer_depth = list.*.layer_depth + 1; } + if (op.kind == DisplayOpKind.end_layer) { list.*.layer_depth = list.*.layer_depth - 1; } + list.*.revision = list.*.revision + 1; + list.*.content_hash = display_hash_op(list.*.content_hash, op); + list.*.last_error = DisplayError.none; + list.*.error_index = 0 - 1; + return DisplayError.none; +} + +fn display_list_seal(list: *DisplayList) DisplayError { + if (list.*.sealed != 0) { return DisplayError.none; } + if (list.*.stack_depth != 0) { return display_fail(list, DisplayError.unbalanced_stack); } + if (list.*.layer_depth != 0) { return display_fail(list, DisplayError.unbalanced_layers); } + list.*.sealed = 1; + list.*.last_error = DisplayError.none; + list.*.error_index = 0 - 1; + return DisplayError.none; +} + +fn display_list_verify(list: DisplayList) DisplayError { + if (list.sealed == 0) { return DisplayError.not_sealed; } + let hash: i64 = 17; + let stack_depth: i32 = 0; + let layer_depth: i32 = 0; + let i: i32 = 0; + while (i < list.ops.len) { + let op: DisplayOp = list.ops.data[i]; + hash = display_hash_op(hash, op); + if (op.kind == DisplayOpKind.save) { stack_depth = stack_depth + 1; } + if (op.kind == DisplayOpKind.restore) { + if (stack_depth <= 0) { return DisplayError.content_mutated; } + stack_depth = stack_depth - 1; + } + if (op.kind == DisplayOpKind.begin_layer) { layer_depth = layer_depth + 1; } + if (op.kind == DisplayOpKind.end_layer) { + if (layer_depth <= 0) { return DisplayError.content_mutated; } + layer_depth = layer_depth - 1; + } + i = i + 1; + } + if (stack_depth != 0 || layer_depth != 0 || hash != list.content_hash || + list.revision != list.ops.len as i64) { + return DisplayError.content_mutated; + } + return DisplayError.none; +} diff --git a/tests/display_list_contract.zag b/tests/display_list_contract.zag new file mode 100644 index 0000000..5232905 --- /dev/null +++ b/tests/display_list_contract.zag @@ -0,0 +1,141 @@ +@import("../src/render/display_list.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn build_scene(color: i64) DisplayList { + let list: DisplayList = display_list_make(); + let owner: NodeKey = node_key(7); + _ = display_list_push(&list, display_op(DisplayOpKind.save, owner)); + + let clip: DisplayOp = display_op(DisplayOpKind.clip_rect, owner); + clip.bounds = rect(0, 0, 200 * unit_scale(), 100 * unit_scale()); + _ = display_list_push(&list, clip); + + let transform: DisplayOp = display_op(DisplayOpKind.concat_transform, owner); + transform.transform.translate_x = 4 * unit_scale(); + transform.transform.translate_y = 8 * unit_scale(); + _ = display_list_push(&list, transform); + + let fill: DisplayOp = display_op(DisplayOpKind.fill_rect, owner); + fill.bounds = rect(0 - unit_scale(), 0, 80 * unit_scale(), 40 * unit_scale()); + fill.paint = paint_rgba16(color, 20000, 30000, 65535); + _ = display_list_push(&list, fill); + + let path: DisplayOp = display_op(DisplayOpKind.draw_path, owner); + path.resource_id = 101; + path.paint = paint_rgba16(65535, 65535, 65535, 50000); + _ = display_list_push(&list, path); + + let image: DisplayOp = display_op(DisplayOpKind.draw_image, owner); + image.resource_id = 202; + image.bounds = rect(10, 20, 32 * unit_scale(), 24 * unit_scale()); + _ = display_list_push(&list, image); + + let glyphs: DisplayOp = display_op(DisplayOpKind.draw_glyph_run, owner); + glyphs.resource_id = 303; + _ = display_list_push(&list, glyphs); + + let layer: DisplayOp = display_op(DisplayOpKind.begin_layer, owner); + layer.bounds = rect(0, 0, 100 * unit_scale(), 50 * unit_scale()); + layer.parameter = 50000; + _ = display_list_push(&list, layer); + + let effect: DisplayOp = display_op(DisplayOpKind.apply_effect, owner); + effect.resource_id = 404; + effect.parameter = 12 * unit_scale(); + _ = display_list_push(&list, effect); + _ = display_list_push(&list, display_op(DisplayOpKind.end_layer, owner)); + _ = display_list_push(&list, display_op(DisplayOpKind.restore, owner)); + return list; +} + +fn scene_contract(state: *TestState) void { + let first: DisplayList = build_scene(10000); + let second: DisplayList = build_scene(10000); + let changed: DisplayList = build_scene(10001); + expect(state, first.ops.len == 11 && first.stack_depth == 0 && first.layer_depth == 0, "display list records a balanced scene"); + expect(state, first.ops.data[4].kind == DisplayOpKind.draw_path && first.ops.data[5].kind == DisplayOpKind.draw_image && first.ops.data[6].kind == DisplayOpKind.draw_glyph_run, "paths, images, and glyph runs remain explicit operations"); + expect(state, first.ops.data[7].kind == DisplayOpKind.begin_layer && first.ops.data[8].kind == DisplayOpKind.apply_effect, "layers and effects remain explicit operations"); + expect(state, first.content_hash == second.content_hash, "identical scenes produce the same deterministic content hash"); + expect(state, first.content_hash != changed.content_hash, "one paint-channel change alters content identity"); + + let old_hash: i64 = first.content_hash; + let old_len: i32 = first.ops.len; + expect(state, display_list_seal(&first) == DisplayError.none && first.sealed == 1, "balanced display lists seal immutably"); + expect(state, display_list_verify(first) == DisplayError.none, "sealed content verifies against its deterministic identity"); + expect(state, display_list_push(&first, display_op(DisplayOpKind.save, node_key(7))) == DisplayError.sealed, "sealed display lists reject mutation"); + expect(state, first.ops.len == old_len && first.content_hash == old_hash, "rejected mutation preserves list content and identity"); + first.ops.data[3].paint.red = first.ops.data[3].paint.red + 1; + expect(state, display_list_verify(first) == DisplayError.content_mutated, "out-of-contract raw mutation is detected before rendering"); + first.ops.data[3].paint.red = first.ops.data[3].paint.red - 1; + expect(state, display_list_verify(first) == DisplayError.none, "restored sealed content verifies again"); + + display_list_free(&changed); + display_list_free(&second); + display_list_free(&first); +} + +fn rejection_contract(state: *TestState) void { + let list: DisplayList = display_list_make(); + expect(state, display_list_verify(list) == DisplayError.not_sealed, "unsealed builders cannot claim immutable verification"); + expect(state, display_list_push(&list, display_op(DisplayOpKind.restore, node_key(1))) == DisplayError.stack_underflow && list.ops.len == 0, "restore underflow fails before mutation"); + expect(state, display_list_push(&list, display_op(DisplayOpKind.end_layer, node_key(1))) == DisplayError.layer_underflow && list.ops.len == 0, "layer underflow fails before mutation"); + + let invalid_rect: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(1)); + invalid_rect.bounds = rect(0, 0, 0, 10); + expect(state, display_list_push(&list, invalid_rect) == DisplayError.invalid_geometry, "empty draw geometry fails visibly"); + + let missing_image: DisplayOp = display_op(DisplayOpKind.draw_image, node_key(1)); + missing_image.bounds = rect(0, 0, 10, 10); + expect(state, display_list_push(&list, missing_image) == DisplayError.invalid_resource, "resource operations require explicit identity"); + + let bad_layer: DisplayOp = display_op(DisplayOpKind.begin_layer, node_key(1)); + bad_layer.bounds = rect(0, 0, 10, 10); + bad_layer.parameter = 70000; + expect(state, display_list_push(&list, bad_layer) == DisplayError.invalid_parameter, "layer opacity outside RGBA16 range is rejected"); + + _ = display_list_push(&list, display_op(DisplayOpKind.save, node_key(1))); + expect(state, display_list_seal(&list) == DisplayError.unbalanced_stack && list.sealed == 0, "unbalanced save state cannot seal"); + _ = display_list_push(&list, display_op(DisplayOpKind.restore, node_key(1))); + expect(state, display_list_seal(&list) == DisplayError.none && list.sealed == 1, "a corrected stack seals without rebuilding the list"); + display_list_free(&list); +} + +fn deterministic_sweep(state: *TestState) void { + let expected_hash: i64 = 0; + let stable: i32 = 1; + let i: i32 = 0; + while (i < 50) { + let list: DisplayList = build_scene(12000); + if (i == 0) { expected_hash = list.content_hash; } + if (list.content_hash != expected_hash) { stable = 0; } + display_list_free(&list); + i = i + 1; + } + expect(state, stable, "50-build sweep preserves deterministic operation identity"); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + scene_contract(&state); + rejection_contract(&state); + deterministic_sweep(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Display-list contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); + _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index d9df64f..43363c5 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -22,5 +22,7 @@ cd "$root" "$tmp/semantics-contract" "$znc" tests/talkback_contract.zag --no-zagd --no-analyze --no-foreground-cache -o "$tmp/talkback-contract" "$tmp/talkback-contract" +"$znc" tests/display_list_contract.zag --no-zagd --no-analyze --no-foreground-cache -o "$tmp/display-list-contract" +"$tmp/display-list-contract" -printf 'headless test: PASS (state, reconciliation, constraints, Flex, semantics, and Zagkit Talkback)\n' +printf 'headless test: PASS (state, reconciliation, constraints, Flex, semantics, Talkback, and display lists)\n' From c82a813aab0609e10627031308e0ee39f1a79600 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 05:40:10 -0700 Subject: [PATCH 009/103] Add display replay and CPU raster oracle --- .github/workflows/contracts.yml | 18 +- CHANGELOG.md | 6 + README.md | 20 +- docs/architecture/README.md | 12 ++ src/render/cpu_raster.zag | 254 ++++++++++++++++++++++++++ src/render/display_list.zag | 32 +++- src/render/display_list_codec.zag | 184 +++++++++++++++++++ tests/cpu_raster_contract.zag | 141 ++++++++++++++ tests/display_list_codec_contract.zag | 131 +++++++++++++ tests/display_list_contract.zag | 5 + tools/check-contracts.sh | 9 + tools/test-headless.sh | 16 +- 12 files changed, 818 insertions(+), 10 deletions(-) create mode 100644 src/render/cpu_raster.zag create mode 100644 src/render/display_list_codec.zag create mode 100644 tests/cpu_raster_contract.zag create mode 100644 tests/display_list_codec_contract.zag diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index a82f7f1..b7e52c7 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -10,10 +10,26 @@ permissions: jobs: validate: runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 15 steps: - name: Check out source uses: actions/checkout@v6 + - name: Resolve pinned Zag revision + id: toolchain + run: echo "zag_sha=$(jq -r '.zag.commit' contracts/toolchain.json)" >> "$GITHUB_OUTPUT" + - name: Check out pinned Zag toolchain + uses: actions/checkout@v6 + with: + repository: Sylorlabs/zag + ref: ${{ steps.toolchain.outputs.zag_sha }} + path: .toolchain/zag + - name: Run strict headless contracts + env: + ZNC: ${{ github.workspace }}/.toolchain/zag/zag-poc/znc + run: | + chmod +x "$ZNC" .toolchain/zag/zag-poc/bootstrap.sh + ZAG_BOOTSTRAP_MEMORY_GUARD=off .toolchain/zag/zag-poc/bootstrap.sh + ./tools/test-headless.sh - name: Validate product contracts run: ./tools/check-contracts.sh - name: Reject whitespace errors diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e3e38f..9144dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ include a generated capability record and exact Zag compiler revision. contract with ID-first targeting and explicit scale-aware pixel fallback. - An experimental immutable display list for explicit paints, resources, clips, transforms, layers, and effects with deterministic content identity. +- A versioned canonical display-list codec with bounded validated decoding and + byte-identical replay round trips. +- The first deterministic CPU-oracle subset for fixed-point fills, clipping, + axis-aligned transforms, fractional coverage, and source-over alpha. +- Strict executable headless contracts in CI using the exact pinned Zag + revision rather than an ambient compiler. ## 0.1.0-experimental.0 (unreleased) diff --git a/README.md b/README.md index ac180fb..df6cd58 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,17 @@ fixed-point geometry, RGBA16 paints, paths, images, glyph runs, layers, and effects as explicit operations. Invalid geometry, resources, parameters, and stack balance fail before mutation; the builder rejects writes after sealing, and verification detects out-of-contract raw mutation against deterministic -content identity. Path/resource storage, serialization, damage, CPU -rasterization, and GPU transport remain open. +content identity. Path/resource storage, resource serialization, damage, CPU +rasterization, and GPU transport remain open. The first versioned binary codec +now round-trips sealed lists byte-identically and rejects malformed, truncated, +noncanonical, unknown-version, and hash-mismatched input; schema evolution, +resource payloads, and fuzz coverage remain open. + +The first CPU-oracle subset rasterizes fixed-point rectangle fills with exact +clip and axis-aligned transform state, area-based fractional edge coverage, and +deterministic source-over alpha into owned RGBA8 surfaces. Unsupported paths, +images, glyphs, strokes, skew, layers, and effects fail at the exact operation; +this subset is not yet the complete CPU renderer required by Milestone 2. Run the deterministic headless foundation test with: @@ -92,6 +101,13 @@ Run the deterministic headless foundation test with: ./tools/test-headless.sh ``` +The script compiles every executable contract with strict Zag semantic +analysis. CI checks out the exact compiler revision from the toolchain contract +self-hosts that pinned source to a byte-identical compiler fixed point, and runs +the same suite before validating repository metadata. The committed upstream +seed is bootstrap authority, not evidence that it already contains later +compiler-source fixes. + ## Build order 1. Advance reusable compiler, ABI, concurrency, package, and platform features diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 198a29d..6117e54 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -47,3 +47,15 @@ RGBA16 paints, layers, and effects remain explicit rather than backend calls. Balanced lists seal with deterministic content identity, and verification detects mutation outside the builder contract before rendering. Resource storage, serialization, damage, the CPU oracle, and GPU transports remain open. + +Display-list replay uses the versioned little-endian `ZKDL` codec. Decoding is +bounded to one million operations, reconstructs operations only through the +same validation path as live building, requires balanced seal state, verifies +the stored content identity and revision, and rejects trailing bytes so one +scene has one canonical encoding. Resource payload serialization remains open. + +The CPU oracle begins with deterministic RGBA8 rectangle rasterization from +26.6 fixed-point geometry. Clip and positive axis-aligned transform state, +fractional edge coverage, and source-over alpha are integer-only. Every +unsupported display operation fails at its exact index instead of silently +degrading or claiming a visual result. diff --git a/src/render/cpu_raster.zag b/src/render/cpu_raster.zag new file mode 100644 index 0000000..b7672b0 --- /dev/null +++ b/src/render/cpu_raster.zag @@ -0,0 +1,254 @@ +@import("std:list") +@import("display_list.zag") + +enum CpuRasterError { + none, + invalid_surface, + unsealed_display_list, + mutated_display_list, + invalid_geometry, + unsupported_operation, +} + +struct CpuSurface { + width: i32, + height: i32, + pixels: ArrayList[u8], +} + +struct CpuRasterState { + clip: Rect, + transform: Transform, +} + +struct CpuRasterResult { + surface: CpuSurface, + error: CpuRasterError, + error_op: i32, + rendered_ops: i32, + pixels_touched: i64, +} + +fn cpu_surface_make(width: i32, height: i32) CpuSurface { + let pixels: ArrayList[u8] = make[u8](1); + if (width <= 0 || height <= 0 || width > 8192 || height > 8192) { + return CpuSurface{ .width = 0, .height = 0, .pixels = pixels }; + } + let byte_count: i64 = (width as i64) * (height as i64) * 4; + if (byte_count <= 0 || byte_count > 268435456) { + return CpuSurface{ .width = 0, .height = 0, .pixels = pixels }; + } + free[u8](&pixels); + pixels = make[u8](byte_count as i32); + let i: i64 = 0; + while (i < byte_count) { push[u8](&pixels, 0); i = i + 1; } + return CpuSurface{ .width = width, .height = height, .pixels = pixels }; +} + +fn cpu_surface_free(surface: *CpuSurface) void { + free[u8](&surface.*.pixels); +} + +fn cpu_raster_free(result: *CpuRasterResult) void { + cpu_surface_free(&result.*.surface); +} + +fn cpu_surface_hash(surface: CpuSurface) i64 { + let hash: i64 = display_mix(display_mix(17, surface.width as i64), surface.height as i64); + let i: i32 = 0; + while (i < surface.pixels.len) { + hash = display_mix(hash, surface.pixels.data[i] as i64); + i = i + 1; + } + return hash; +} + +fn cpu_result(surface: CpuSurface, error: CpuRasterError, error_op: i32, rendered_ops: i32, pixels_touched: i64) CpuRasterResult { + return CpuRasterResult{ + .surface = surface, + .error = error, + .error_op = error_op, + .rendered_ops = rendered_ops, + .pixels_touched = pixels_touched, + }; +} + +fn cpu_rect_intersection(a: Rect, b: Rect) Rect { + let left: i64 = a.x; + if (b.x > left) { left = b.x; } + let top: i64 = a.y; + if (b.y > top) { top = b.y; } + let right: i64 = a.x + a.width; + if (b.x + b.width < right) { right = b.x + b.width; } + let bottom: i64 = a.y + a.height; + if (b.y + b.height < bottom) { bottom = b.y + b.height; } + if (right <= left || bottom <= top) { return rect(0, 0, 0, 0); } + return rect(left, top, right - left, bottom - top); +} + +fn cpu_fixed_safe(value: i64) i32 { + return (value >= -1000000000000 && value <= 1000000000000) as i32; +} + +fn cpu_transform_safe(transform: Transform) i32 { + let max_scale: i64 = 4096 * unit_scale(); + return (transform.scale_x > 0 && transform.scale_x <= max_scale && + transform.scale_y > 0 && transform.scale_y <= max_scale && + transform.skew_x == 0 && transform.skew_y == 0 && + cpu_fixed_safe(transform.translate_x) != 0 && + cpu_fixed_safe(transform.translate_y) != 0) as i32; +} + +fn cpu_transform_rect(value: Rect, transform: Transform) Rect { + return rect( + (value.x * transform.scale_x) / unit_scale() + transform.translate_x, + (value.y * transform.scale_y) / unit_scale() + transform.translate_y, + (value.width * transform.scale_x) / unit_scale(), + (value.height * transform.scale_y) / unit_scale() + ); +} + +fn cpu_compose_transform(current: Transform, next: Transform) Transform { + return Transform{ + .scale_x = (current.scale_x * next.scale_x) / unit_scale(), + .skew_x = 0, + .skew_y = 0, + .scale_y = (current.scale_y * next.scale_y) / unit_scale(), + .translate_x = current.translate_x + (current.scale_x * next.translate_x) / unit_scale(), + .translate_y = current.translate_y + (current.scale_y * next.translate_y) / unit_scale(), + }; +} + +fn cpu_blend_pixel(surface: *CpuSurface, x: i32, y: i32, paint: Paint, coverage: i64) void { + if (coverage <= 0) { return; } + let index: i32 = (y * surface.*.width + x) * 4; + let source_r: i64 = (paint.red * 255 + 32767) / 65535; + let source_g: i64 = (paint.green * 255 + 32767) / 65535; + let source_b: i64 = (paint.blue * 255 + 32767) / 65535; + let paint_alpha: i64 = (paint.alpha * 255 + 32767) / 65535; + let source_a: i64 = (paint_alpha * coverage + 2048) / 4096; + let dest_r: i64 = surface.*.pixels.data[index] as i64; + let dest_g: i64 = surface.*.pixels.data[index + 1] as i64; + let dest_b: i64 = surface.*.pixels.data[index + 2] as i64; + let dest_a: i64 = surface.*.pixels.data[index + 3] as i64; + let inverse_a: i64 = 255 - source_a; + let out_alpha_numerator: i64 = source_a * 255 + dest_a * inverse_a; + if (out_alpha_numerator <= 0) { + surface.*.pixels.data[index] = 0; + surface.*.pixels.data[index + 1] = 0; + surface.*.pixels.data[index + 2] = 0; + surface.*.pixels.data[index + 3] = 0; + return; + } + let out_r_numerator: i64 = source_r * source_a * 255 + dest_r * dest_a * inverse_a; + let out_g_numerator: i64 = source_g * source_a * 255 + dest_g * dest_a * inverse_a; + let out_b_numerator: i64 = source_b * source_a * 255 + dest_b * dest_a * inverse_a; + surface.*.pixels.data[index] = ((out_r_numerator + out_alpha_numerator / 2) / out_alpha_numerator) as u8; + surface.*.pixels.data[index + 1] = ((out_g_numerator + out_alpha_numerator / 2) / out_alpha_numerator) as u8; + surface.*.pixels.data[index + 2] = ((out_b_numerator + out_alpha_numerator / 2) / out_alpha_numerator) as u8; + surface.*.pixels.data[index + 3] = ((out_alpha_numerator + 127) / 255) as u8; +} + +fn cpu_fill_rect(surface: *CpuSurface, raw_bounds: Rect, clip: Rect, paint: Paint) i64 { + let surface_bounds: Rect = rect(0, 0, (surface.*.width as i64) * unit_scale(), (surface.*.height as i64) * unit_scale()); + let bounds: Rect = cpu_rect_intersection(cpu_rect_intersection(raw_bounds, clip), surface_bounds); + if (bounds.width <= 0 || bounds.height <= 0) { return 0; } + let first_x: i32 = (bounds.x / unit_scale()) as i32; + let first_y: i32 = (bounds.y / unit_scale()) as i32; + let last_x: i32 = ((bounds.x + bounds.width + unit_scale() - 1) / unit_scale()) as i32; + let last_y: i32 = ((bounds.y + bounds.height + unit_scale() - 1) / unit_scale()) as i32; + let touched: i64 = 0; + let y: i32 = first_y; + while (y < last_y) { + let pixel_top: i64 = (y as i64) * unit_scale(); + let overlap_top: i64 = bounds.y; + if (pixel_top > overlap_top) { overlap_top = pixel_top; } + let overlap_bottom: i64 = bounds.y + bounds.height; + if (pixel_top + unit_scale() < overlap_bottom) { overlap_bottom = pixel_top + unit_scale(); } + let x: i32 = first_x; + while (x < last_x) { + let pixel_left: i64 = (x as i64) * unit_scale(); + let overlap_left: i64 = bounds.x; + if (pixel_left > overlap_left) { overlap_left = pixel_left; } + let overlap_right: i64 = bounds.x + bounds.width; + if (pixel_left + unit_scale() < overlap_right) { overlap_right = pixel_left + unit_scale(); } + let coverage: i64 = (overlap_right - overlap_left) * (overlap_bottom - overlap_top); + if (coverage > 0) { + cpu_blend_pixel(surface, x, y, paint, coverage); + touched = touched + 1; + } + x = x + 1; + } + y = y + 1; + } + return touched; +} + +fn cpu_rasterize(list: DisplayList, width: i32, height: i32) CpuRasterResult { + let surface: CpuSurface = cpu_surface_make(width, height); + if (surface.width == 0 || surface.height == 0) { + return cpu_result(surface, CpuRasterError.invalid_surface, 0 - 1, 0, 0); + } + if (list.sealed == 0) { + return cpu_result(surface, CpuRasterError.unsealed_display_list, 0 - 1, 0, 0); + } + if (display_list_verify(list) != DisplayError.none) { + return cpu_result(surface, CpuRasterError.mutated_display_list, 0 - 1, 0, 0); + } + + let stack: ArrayList[CpuRasterState] = make[CpuRasterState](16); + let current: CpuRasterState = CpuRasterState{ + .clip = rect(0, 0, (width as i64) * unit_scale(), (height as i64) * unit_scale()), + .transform = transform_identity(), + }; + let rendered_ops: i32 = 0; + let pixels_touched: i64 = 0; + let i: i32 = 0; + while (i < list.ops.len) { + let op: DisplayOp = list.ops.data[i]; + if (op.kind == DisplayOpKind.save) { + push[CpuRasterState](&stack, current); + } else if (op.kind == DisplayOpKind.restore) { + current = pop[CpuRasterState](&stack); + } else if (op.kind == DisplayOpKind.clip_rect) { + if (cpu_fixed_safe(op.bounds.x) == 0 || cpu_fixed_safe(op.bounds.y) == 0 || + cpu_fixed_safe(op.bounds.width) == 0 || cpu_fixed_safe(op.bounds.height) == 0) { + free[CpuRasterState](&stack); + return cpu_result(surface, CpuRasterError.invalid_geometry, i, rendered_ops, pixels_touched); + } + let transformed: Rect = cpu_transform_rect(op.bounds, current.transform); + current.clip = cpu_rect_intersection(current.clip, transformed); + } else if (op.kind == DisplayOpKind.concat_transform) { + if (cpu_transform_safe(op.transform) == 0) { + free[CpuRasterState](&stack); + return cpu_result(surface, CpuRasterError.unsupported_operation, i, rendered_ops, pixels_touched); + } + let composed: Transform = cpu_compose_transform(current.transform, op.transform); + if (cpu_transform_safe(composed) == 0) { + free[CpuRasterState](&stack); + return cpu_result(surface, CpuRasterError.invalid_geometry, i, rendered_ops, pixels_touched); + } + current.transform = composed; + } else if (op.kind == DisplayOpKind.fill_rect) { + if (cpu_fixed_safe(op.bounds.x) == 0 || cpu_fixed_safe(op.bounds.y) == 0 || + cpu_fixed_safe(op.bounds.width) == 0 || cpu_fixed_safe(op.bounds.height) == 0) { + free[CpuRasterState](&stack); + return cpu_result(surface, CpuRasterError.invalid_geometry, i, rendered_ops, pixels_touched); + } + let transformed: Rect = cpu_transform_rect(op.bounds, current.transform); + if (cpu_fixed_safe(transformed.x) == 0 || cpu_fixed_safe(transformed.y) == 0 || + cpu_fixed_safe(transformed.width) == 0 || cpu_fixed_safe(transformed.height) == 0) { + free[CpuRasterState](&stack); + return cpu_result(surface, CpuRasterError.invalid_geometry, i, rendered_ops, pixels_touched); + } + pixels_touched = pixels_touched + cpu_fill_rect(&surface, transformed, current.clip, op.paint); + } else { + free[CpuRasterState](&stack); + return cpu_result(surface, CpuRasterError.unsupported_operation, i, rendered_ops, pixels_touched); + } + rendered_ops = rendered_ops + 1; + i = i + 1; + } + free[CpuRasterState](&stack); + return cpu_result(surface, CpuRasterError.none, 0 - 1, rendered_ops, pixels_touched); +} diff --git a/src/render/display_list.zag b/src/render/display_list.zag index 7d12995..5741d2f 100644 --- a/src/render/display_list.zag +++ b/src/render/display_list.zag @@ -75,6 +75,27 @@ fn display_color_channel(value: i64) i64 { return value; } +fn display_fixed_value_valid(value: i64) i32 { + return (value >= 0 - unbounded_extent() && value <= unbounded_extent()) as i32; +} + +fn display_paint_valid(paint: Paint) i32 { + return (paint.red >= 0 && paint.red <= 65535 && + paint.green >= 0 && paint.green <= 65535 && + paint.blue >= 0 && paint.blue <= 65535 && + paint.alpha >= 0 && paint.alpha <= 65535 && + paint.stroke_width >= 0 && display_fixed_value_valid(paint.stroke_width) != 0) as i32; +} + +fn display_transform_valid(transform: Transform) i32 { + return (display_fixed_value_valid(transform.scale_x) != 0 && + display_fixed_value_valid(transform.skew_x) != 0 && + display_fixed_value_valid(transform.skew_y) != 0 && + display_fixed_value_valid(transform.scale_y) != 0 && + display_fixed_value_valid(transform.translate_x) != 0 && + display_fixed_value_valid(transform.translate_y) != 0) as i32; +} + fn paint_rgba16(red: i64, green: i64, blue: i64, alpha: i64) Paint { return Paint{ .red = display_color_channel(red), @@ -174,6 +195,12 @@ fn display_op_requires_resource(kind: DisplayOpKind) i32 { fn display_list_push(list: *DisplayList, op: DisplayOp) DisplayError { if (list.*.sealed != 0) { return display_fail(list, DisplayError.sealed); } + if (display_fixed_value_valid(op.bounds.x) == 0 || + display_fixed_value_valid(op.bounds.y) == 0 || + display_fixed_value_valid(op.bounds.width) == 0 || + display_fixed_value_valid(op.bounds.height) == 0) { + return display_fail(list, DisplayError.invalid_geometry); + } if (display_op_requires_bounds(op.kind) != 0 && (op.bounds.width <= 0 || op.bounds.height <= 0)) { return display_fail(list, DisplayError.invalid_geometry); @@ -181,7 +208,10 @@ fn display_list_push(list: *DisplayList, op: DisplayOp) DisplayError { if (display_op_requires_resource(op.kind) != 0 && op.resource_id <= 0) { return display_fail(list, DisplayError.invalid_resource); } - if (op.paint.stroke_width < 0) { return display_fail(list, DisplayError.invalid_parameter); } + if (display_paint_valid(op.paint) == 0 || display_transform_valid(op.transform) == 0 || + op.parameter < 0 || display_fixed_value_valid(op.parameter) == 0) { + return display_fail(list, DisplayError.invalid_parameter); + } if (op.kind == DisplayOpKind.begin_layer && (op.parameter < 0 || op.parameter > 65535)) { return display_fail(list, DisplayError.invalid_parameter); } diff --git a/src/render/display_list_codec.zag b/src/render/display_list_codec.zag new file mode 100644 index 0000000..cb0d79d --- /dev/null +++ b/src/render/display_list_codec.zag @@ -0,0 +1,184 @@ +@import("std:list") +@import("display_list.zag") + +enum DisplayCodecError { + none, + not_sealed, + content_mutated, + bad_magic, + unsupported_version, + invalid_count, + truncated, + trailing_data, + invalid_kind, + invalid_operation, + hash_mismatch, +} + +struct DisplayEncodeResult { + bytes: ArrayList[u8], + error: DisplayCodecError, +} + +struct DisplayDecodeResult { + list: DisplayList, + error: DisplayCodecError, + error_offset: i64, +} + +fn display_codec_version() i64 { return 1; } +fn display_codec_header_size() i64 { return 36; } +fn display_codec_op_size() i64 { return 160; } + +fn display_write_i64(bytes: *ArrayList[u8], value: i64) void { + let i: i32 = 0; + while (i < 8) { + push[u8](bytes, ((value >> (i * 8)) & 255) as u8); + i = i + 1; + } +} + +fn display_read_i64(bytes: ArrayList[u8], offset: i64) i64 { + let value: i64 = 0; + let i: i32 = 0; + while (i < 8) { + value = value | ((bytes.data[(offset as i32) + i] as i64) << (i * 8)); + i = i + 1; + } + return value; +} + +fn display_encode_fail(error: DisplayCodecError) DisplayEncodeResult { + return DisplayEncodeResult{ .bytes = make[u8](1), .error = error }; +} + +fn display_list_encode(list: DisplayList) DisplayEncodeResult { + if (list.sealed == 0) { return display_encode_fail(DisplayCodecError.not_sealed); } + if (display_list_verify(list) != DisplayError.none) { + return display_encode_fail(DisplayCodecError.content_mutated); + } + if (list.ops.len < 0 || list.ops.len > 1000000) { + return display_encode_fail(DisplayCodecError.invalid_count); + } + + let bytes: ArrayList[u8] = make[u8]((display_codec_header_size() + (list.ops.len as i64) * display_codec_op_size()) as i32); + push[u8](&bytes, 90); push[u8](&bytes, 75); push[u8](&bytes, 68); push[u8](&bytes, 76); + display_write_i64(&bytes, display_codec_version()); + display_write_i64(&bytes, list.ops.len as i64); + display_write_i64(&bytes, list.content_hash); + display_write_i64(&bytes, list.revision); + + let i: i32 = 0; + while (i < list.ops.len) { + let op: DisplayOp = list.ops.data[i]; + display_write_i64(&bytes, op.kind as i64); + display_write_i64(&bytes, op.owner.value); + display_write_i64(&bytes, op.owner.generation); + display_write_i64(&bytes, op.bounds.x); + display_write_i64(&bytes, op.bounds.y); + display_write_i64(&bytes, op.bounds.width); + display_write_i64(&bytes, op.bounds.height); + display_write_i64(&bytes, op.paint.red); + display_write_i64(&bytes, op.paint.green); + display_write_i64(&bytes, op.paint.blue); + display_write_i64(&bytes, op.paint.alpha); + display_write_i64(&bytes, op.paint.stroke_width); + display_write_i64(&bytes, op.transform.scale_x); + display_write_i64(&bytes, op.transform.skew_x); + display_write_i64(&bytes, op.transform.skew_y); + display_write_i64(&bytes, op.transform.scale_y); + display_write_i64(&bytes, op.transform.translate_x); + display_write_i64(&bytes, op.transform.translate_y); + display_write_i64(&bytes, op.resource_id); + display_write_i64(&bytes, op.parameter); + i = i + 1; + } + return DisplayEncodeResult{ .bytes = bytes, .error = DisplayCodecError.none }; +} + +fn display_encode_free(result: *DisplayEncodeResult) void { + free[u8](&result.*.bytes); +} + +fn display_decode_fail(list: DisplayList, error: DisplayCodecError, offset: i64) DisplayDecodeResult { + return DisplayDecodeResult{ .list = list, .error = error, .error_offset = offset }; +} + +fn display_list_decode(bytes: ArrayList[u8]) DisplayDecodeResult { + let list: DisplayList = display_list_make(); + if ((bytes.len as i64) < display_codec_header_size()) { + return display_decode_fail(list, DisplayCodecError.truncated, bytes.len as i64); + } + if (bytes.data[0] != 90 || bytes.data[1] != 75 || bytes.data[2] != 68 || bytes.data[3] != 76) { + return display_decode_fail(list, DisplayCodecError.bad_magic, 0); + } + let version: i64 = display_read_i64(bytes, 4); + if (version != display_codec_version()) { + return display_decode_fail(list, DisplayCodecError.unsupported_version, 4); + } + let count: i64 = display_read_i64(bytes, 12); + if (count < 0 || count > 1000000) { + return display_decode_fail(list, DisplayCodecError.invalid_count, 12); + } + let expected_size: i64 = display_codec_header_size() + count * display_codec_op_size(); + if ((bytes.len as i64) < expected_size) { + return display_decode_fail(list, DisplayCodecError.truncated, bytes.len as i64); + } + if ((bytes.len as i64) > expected_size) { + return display_decode_fail(list, DisplayCodecError.trailing_data, expected_size); + } + let stored_hash: i64 = display_read_i64(bytes, 20); + let stored_revision: i64 = display_read_i64(bytes, 28); + + let offset: i64 = display_codec_header_size(); + let i: i64 = 0; + while (i < count) { + let kind_code: i64 = display_read_i64(bytes, offset); + if (kind_code < 0 || kind_code > 11) { + return display_decode_fail(list, DisplayCodecError.invalid_kind, offset); + } + let op: DisplayOp = display_op(kind_code as DisplayOpKind, node_key_scoped( + display_read_i64(bytes, offset + 8), + display_read_i64(bytes, offset + 16) + )); + op.bounds = rect( + display_read_i64(bytes, offset + 24), + display_read_i64(bytes, offset + 32), + display_read_i64(bytes, offset + 40), + display_read_i64(bytes, offset + 48) + ); + op.paint = Paint{ + .red = display_read_i64(bytes, offset + 56), + .green = display_read_i64(bytes, offset + 64), + .blue = display_read_i64(bytes, offset + 72), + .alpha = display_read_i64(bytes, offset + 80), + .stroke_width = display_read_i64(bytes, offset + 88), + }; + op.transform = Transform{ + .scale_x = display_read_i64(bytes, offset + 96), + .skew_x = display_read_i64(bytes, offset + 104), + .skew_y = display_read_i64(bytes, offset + 112), + .scale_y = display_read_i64(bytes, offset + 120), + .translate_x = display_read_i64(bytes, offset + 128), + .translate_y = display_read_i64(bytes, offset + 136), + }; + op.resource_id = display_read_i64(bytes, offset + 144); + op.parameter = display_read_i64(bytes, offset + 152); + if (display_list_push(&list, op) != DisplayError.none) { + return display_decode_fail(list, DisplayCodecError.invalid_operation, offset); + } + offset = offset + display_codec_op_size(); + i = i + 1; + } + if (display_list_seal(&list) != DisplayError.none) { + return display_decode_fail(list, DisplayCodecError.invalid_operation, offset); + } + if (list.content_hash != stored_hash || list.revision != stored_revision) { + return display_decode_fail(list, DisplayCodecError.hash_mismatch, 20); + } + return DisplayDecodeResult{ .list = list, .error = DisplayCodecError.none, .error_offset = 0 - 1 }; +} + +fn display_decode_free(result: *DisplayDecodeResult) void { + display_list_free(&result.*.list); +} diff --git a/tests/cpu_raster_contract.zag b/tests/cpu_raster_contract.zag new file mode 100644 index 0000000..928ea82 --- /dev/null +++ b/tests/cpu_raster_contract.zag @@ -0,0 +1,141 @@ +@import("../src/render/cpu_raster.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn pixel(surface: CpuSurface, x: i32, y: i32, channel: i32) i64 { + return surface.pixels.data[(y * surface.width + x) * 4 + channel] as i64; +} + +fn fill_scene(bounds: Rect, paint: Paint) DisplayList { + let list: DisplayList = display_list_make(); + let op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(1)); + op.bounds = bounds; + op.paint = paint; + _ = display_list_push(&list, op); + _ = display_list_seal(&list); + return list; +} + +fn exact_fill_contract(state: *TestState) void { + let list: DisplayList = fill_scene(rect(0, 0, 2 * unit_scale(), 2 * unit_scale()), paint_rgba16(65535, 0, 0, 65535)); + let result: CpuRasterResult = cpu_rasterize(list, 3, 3); + expect(state, result.error == CpuRasterError.none && result.rendered_ops == 1, "sealed fill list rasterizes successfully"); + expect(state, pixel(result.surface, 0, 0, 0) == 255 && pixel(result.surface, 0, 0, 3) == 255, "opaque RGBA16 red resolves exactly to RGBA8"); + expect(state, pixel(result.surface, 1, 1, 0) == 255 && pixel(result.surface, 2, 2, 3) == 0, "fill bounds include only covered pixels"); + expect(state, result.pixels_touched == 4, "raster evidence counts touched pixels exactly"); + cpu_raster_free(&result); + display_list_free(&list); +} + +fn coverage_and_blend_contract(state: *TestState) void { + let half: DisplayList = fill_scene(rect(unit_scale() / 2, 0, unit_scale(), unit_scale()), paint_rgba16(65535, 0, 0, 65535)); + let edge: CpuRasterResult = cpu_rasterize(half, 2, 1); + expect(state, pixel(edge.surface, 0, 0, 0) == 255 && pixel(edge.surface, 0, 0, 3) == 128, "left fractional edge receives exact half coverage"); + expect(state, pixel(edge.surface, 1, 0, 0) == 255 && pixel(edge.surface, 1, 0, 3) == 128, "right fractional edge receives matching half coverage"); + cpu_raster_free(&edge); + display_list_free(&half); + + let blend: DisplayList = display_list_make(); + let blue: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(1)); + blue.bounds = rect(0, 0, unit_scale(), unit_scale()); + blue.paint = paint_rgba16(0, 0, 65535, 65535); + _ = display_list_push(&blend, blue); + let red: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(2)); + red.bounds = blue.bounds; + red.paint = paint_rgba16(65535, 0, 0, 32768); + _ = display_list_push(&blend, red); + _ = display_list_seal(&blend); + let blended: CpuRasterResult = cpu_rasterize(blend, 1, 1); + expect(state, pixel(blended.surface, 0, 0, 0) == 128 && pixel(blended.surface, 0, 0, 2) == 127 && pixel(blended.surface, 0, 0, 3) == 255, "source-over alpha is deterministic and rounded symmetrically"); + cpu_raster_free(&blended); + display_list_free(&blend); +} + +fn clip_and_transform_contract(state: *TestState) void { + let list: DisplayList = display_list_make(); + _ = display_list_push(&list, display_op(DisplayOpKind.save, node_key(1))); + let clip: DisplayOp = display_op(DisplayOpKind.clip_rect, node_key(1)); + clip.bounds = rect(unit_scale(), 0, 2 * unit_scale(), 2 * unit_scale()); + _ = display_list_push(&list, clip); + let transform: DisplayOp = display_op(DisplayOpKind.concat_transform, node_key(1)); + transform.transform.translate_x = unit_scale(); + _ = display_list_push(&list, transform); + let fill: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(1)); + fill.bounds = rect(0, 0, 3 * unit_scale(), 2 * unit_scale()); + fill.paint = paint_rgba16(0, 65535, 0, 65535); + _ = display_list_push(&list, fill); + _ = display_list_push(&list, display_op(DisplayOpKind.restore, node_key(1))); + _ = display_list_seal(&list); + let result: CpuRasterResult = cpu_rasterize(list, 4, 2); + expect(state, pixel(result.surface, 0, 0, 3) == 0 && pixel(result.surface, 1, 0, 1) == 255 && pixel(result.surface, 2, 1, 1) == 255 && pixel(result.surface, 3, 0, 3) == 0, "clip and translation compose in fixed-point logical space"); + expect(state, result.rendered_ops == 5 && result.pixels_touched == 4, "state operations and clipped work are counted exactly"); + cpu_raster_free(&result); + display_list_free(&list); +} + +fn deterministic_contract(state: *TestState) void { + let list: DisplayList = fill_scene(rect(10, 15, 3 * unit_scale(), 2 * unit_scale()), paint_rgba16(12000, 23000, 34000, 45000)); + let first: CpuRasterResult = cpu_rasterize(list, 5, 4); + let second: CpuRasterResult = cpu_rasterize(list, 5, 4); + expect(state, cpu_surface_hash(first.surface) == cpu_surface_hash(second.surface), "identical sealed lists produce identical CPU pixels"); + cpu_raster_free(&second); + cpu_raster_free(&first); + display_list_free(&list); +} + +fn failure_contract(state: *TestState) void { + let unsealed: DisplayList = display_list_make(); + let unsealed_result: CpuRasterResult = cpu_rasterize(unsealed, 2, 2); + expect(state, unsealed_result.error == CpuRasterError.unsealed_display_list, "unsealed display lists cannot reach the CPU oracle"); + cpu_raster_free(&unsealed_result); + display_list_free(&unsealed); + + let unsupported: DisplayList = display_list_make(); + let path: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(1)); + path.resource_id = 8; + _ = display_list_push(&unsupported, path); + _ = display_list_seal(&unsupported); + let unsupported_result: CpuRasterResult = cpu_rasterize(unsupported, 2, 2); + expect(state, unsupported_result.error == CpuRasterError.unsupported_operation && unsupported_result.error_op == 0, "unimplemented path rasterization fails closed at the exact operation"); + cpu_raster_free(&unsupported_result); + display_list_free(&unsupported); + + let invalid_surface: DisplayList = fill_scene(rect(0, 0, 1, 1), paint_rgba16(0, 0, 0, 0)); + let invalid_result: CpuRasterResult = cpu_rasterize(invalid_surface, 0, 10); + expect(state, invalid_result.error == CpuRasterError.invalid_surface, "invalid surface dimensions fail without allocation claims"); + cpu_raster_free(&invalid_result); + display_list_free(&invalid_surface); + + let extreme: DisplayList = fill_scene(rect(1000000000001, 0, 1, 1), paint_rgba16(0, 0, 0, 65535)); + let extreme_result: CpuRasterResult = cpu_rasterize(extreme, 2, 2); + expect(state, extreme_result.error == CpuRasterError.invalid_geometry && extreme_result.error_op == 0, "unsafe transform arithmetic is rejected before multiplication"); + cpu_raster_free(&extreme_result); + display_list_free(&extreme); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + exact_fill_contract(&state); + coverage_and_blend_contract(&state); + clip_and_transform_contract(&state); + deterministic_contract(&state); + failure_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("CPU raster contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); + _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tests/display_list_codec_contract.zag b/tests/display_list_codec_contract.zag new file mode 100644 index 0000000..15f8a76 --- /dev/null +++ b/tests/display_list_codec_contract.zag @@ -0,0 +1,131 @@ +@import("../src/render/display_list_codec.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn codec_scene() DisplayList { + let list: DisplayList = display_list_make(); + let owner: NodeKey = node_key_scoped(42, 3); + _ = display_list_push(&list, display_op(DisplayOpKind.save, owner)); + let clip: DisplayOp = display_op(DisplayOpKind.clip_rect, owner); + clip.bounds = rect(0 - unit_scale(), 2 * unit_scale(), 80 * unit_scale(), 40 * unit_scale()); + _ = display_list_push(&list, clip); + let fill: DisplayOp = display_op(DisplayOpKind.fill_rect, owner); + fill.bounds = rect(3 * unit_scale(), 4 * unit_scale(), 20 * unit_scale(), 10 * unit_scale()); + fill.paint = paint_rgba16(1234, 2345, 3456, 4567); + _ = display_list_push(&list, fill); + _ = display_list_push(&list, display_op(DisplayOpKind.restore, owner)); + _ = display_list_seal(&list); + return list; +} + +fn bytes_equal(a: ArrayList[u8], b: ArrayList[u8]) i32 { + if (a.len != b.len) { return 0; } + let i: i32 = 0; + while (i < a.len) { + if (a.data[i] != b.data[i]) { return 0; } + i = i + 1; + } + return 1; +} + +fn round_trip_contract(state: *TestState) void { + let source: DisplayList = codec_scene(); + let encoded: DisplayEncodeResult = display_list_encode(source); + expect(state, encoded.error == DisplayCodecError.none && encoded.bytes.len == 36 + 4 * 160, "sealed display list encodes to the versioned exact size"); + expect(state, encoded.bytes.data[0] == 90 && encoded.bytes.data[1] == 75 && encoded.bytes.data[2] == 68 && encoded.bytes.data[3] == 76, "codec carries the ZKDL magic"); + + let decoded: DisplayDecodeResult = display_list_decode(encoded.bytes); + expect(state, decoded.error == DisplayCodecError.none && decoded.list.sealed == 1, "valid bytes decode to a sealed display list"); + expect(state, decoded.list.content_hash == source.content_hash && decoded.list.revision == source.revision, "round trip preserves deterministic identity"); + expect(state, decoded.list.ops.data[2].owner.value == 42 && decoded.list.ops.data[2].owner.generation == 3, "round trip preserves retained ownership"); + expect(state, decoded.list.ops.data[1].bounds.x == 0 - unit_scale() && decoded.list.ops.data[2].paint.alpha == 4567, "round trip preserves signed geometry and RGBA16 paint"); + + let encoded_again: DisplayEncodeResult = display_list_encode(decoded.list); + expect(state, bytes_equal(encoded.bytes, encoded_again.bytes) == 1, "decode and re-encode are byte identical"); + display_encode_free(&encoded_again); + display_decode_free(&decoded); + display_encode_free(&encoded); + display_list_free(&source); +} + +fn malformed_contract(state: *TestState) void { + let source: DisplayList = codec_scene(); + let encoded: DisplayEncodeResult = display_list_encode(source); + + let saved: u8 = encoded.bytes.data[0]; + encoded.bytes.data[0] = 0; + let bad_magic: DisplayDecodeResult = display_list_decode(encoded.bytes); + expect(state, bad_magic.error == DisplayCodecError.bad_magic && bad_magic.error_offset == 0, "bad magic fails at byte zero"); + display_decode_free(&bad_magic); + encoded.bytes.data[0] = saved; + + let saved_version: u8 = encoded.bytes.data[4]; + encoded.bytes.data[4] = 2; + let bad_version: DisplayDecodeResult = display_list_decode(encoded.bytes); + expect(state, bad_version.error == DisplayCodecError.unsupported_version && bad_version.error_offset == 4, "unsupported versions fail visibly"); + display_decode_free(&bad_version); + encoded.bytes.data[4] = saved_version; + + let saved_kind: u8 = encoded.bytes.data[36]; + encoded.bytes.data[36] = 99; + let bad_kind: DisplayDecodeResult = display_list_decode(encoded.bytes); + expect(state, bad_kind.error == DisplayCodecError.invalid_kind && bad_kind.error_offset == 36, "unknown operation kinds fail at the exact record"); + display_decode_free(&bad_kind); + encoded.bytes.data[36] = saved_kind; + + let saved_paint: u8 = encoded.bytes.data[36 + 2 * 160 + 56]; + encoded.bytes.data[36 + 2 * 160 + 56] = saved_paint + 1; + let bad_hash: DisplayDecodeResult = display_list_decode(encoded.bytes); + expect(state, bad_hash.error == DisplayCodecError.hash_mismatch, "content tampering fails deterministic identity verification"); + display_decode_free(&bad_hash); + encoded.bytes.data[36 + 2 * 160 + 56] = saved_paint; + + let truncated: ArrayList[u8] = make[u8](20); + let i: i32 = 0; + while (i < 20) { push[u8](&truncated, encoded.bytes.data[i]); i = i + 1; } + let short_result: DisplayDecodeResult = display_list_decode(truncated); + expect(state, short_result.error == DisplayCodecError.truncated && short_result.error_offset == 20, "truncated headers fail with the observed length"); + display_decode_free(&short_result); + free[u8](&truncated); + + push[u8](&encoded.bytes, 0); + let trailing: DisplayDecodeResult = display_list_decode(encoded.bytes); + expect(state, trailing.error == DisplayCodecError.trailing_data, "trailing bytes fail canonical decoding"); + display_decode_free(&trailing); + + display_encode_free(&encoded); + display_list_free(&source); +} + +fn encode_rejection_contract(state: *TestState) void { + let unsealed: DisplayList = display_list_make(); + let result: DisplayEncodeResult = display_list_encode(unsealed); + expect(state, result.error == DisplayCodecError.not_sealed && result.bytes.len == 0, "unsealed builders cannot be serialized as immutable replay input"); + display_encode_free(&result); + display_list_free(&unsealed); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + round_trip_contract(&state); + malformed_contract(&state); + encode_rejection_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Display-list codec contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); + _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tests/display_list_contract.zag b/tests/display_list_contract.zag index 5232905..386a100 100644 --- a/tests/display_list_contract.zag +++ b/tests/display_list_contract.zag @@ -104,6 +104,11 @@ fn rejection_contract(state: *TestState) void { bad_layer.parameter = 70000; expect(state, display_list_push(&list, bad_layer) == DisplayError.invalid_parameter, "layer opacity outside RGBA16 range is rejected"); + let bad_paint: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(1)); + bad_paint.bounds = rect(0, 0, 10, 10); + bad_paint.paint.red = 65536; + expect(state, display_list_push(&list, bad_paint) == DisplayError.invalid_parameter, "raw paint channels outside RGBA16 fail before mutation"); + _ = display_list_push(&list, display_op(DisplayOpKind.save, node_key(1))); expect(state, display_list_seal(&list) == DisplayError.unbalanced_stack && list.sealed == 0, "unbalanced save state cannot seal"); _ = display_list_push(&list, display_op(DisplayOpKind.restore, node_key(1))); diff --git a/tools/check-contracts.sh b/tools/check-contracts.sh index 605cc8d..ec16e7f 100755 --- a/tools/check-contracts.sh +++ b/tools/check-contracts.sh @@ -30,6 +30,15 @@ for path in \ require_file "$path" done +grep -q 'Run strict headless contracts' .github/workflows/contracts.yml \ + || fail "CI no longer executes strict headless contracts" +grep -q 'contracts/toolchain.json' .github/workflows/contracts.yml \ + || fail "CI no longer resolves its Zag revision from the toolchain contract" +grep -q 'ZAG_BOOTSTRAP_MEMORY_GUARD=off .toolchain/zag/zag-poc/bootstrap.sh' .github/workflows/contracts.yml \ + || fail "CI no longer self-hosts the pinned Zag source before testing" +grep -q -- '--analyze-strict' tools/test-headless.sh \ + || fail "headless tests no longer enforce strict Zag analysis" + for path in \ docs/rfcs/0000-rfc-process.md \ docs/rfcs/0001-product-and-platform-contract.md \ diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 43363c5..005c41f 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -14,15 +14,19 @@ cleanup() { rm -rf "$tmp"; } trap cleanup EXIT cd "$root" -"$znc" tests/flex_contract.zag --no-zagd --no-analyze --no-foreground-cache -o "$tmp/flex-contract" +"$znc" tests/flex_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/flex-contract" "$tmp/flex-contract" -"$znc" tests/state_reconcile_contract.zag --no-zagd --no-analyze --no-foreground-cache -o "$tmp/state-reconcile-contract" +"$znc" tests/state_reconcile_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/state-reconcile-contract" "$tmp/state-reconcile-contract" -"$znc" tests/semantics_contract.zag --no-zagd --no-analyze --no-foreground-cache -o "$tmp/semantics-contract" +"$znc" tests/semantics_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/semantics-contract" "$tmp/semantics-contract" -"$znc" tests/talkback_contract.zag --no-zagd --no-analyze --no-foreground-cache -o "$tmp/talkback-contract" +"$znc" tests/talkback_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/talkback-contract" "$tmp/talkback-contract" -"$znc" tests/display_list_contract.zag --no-zagd --no-analyze --no-foreground-cache -o "$tmp/display-list-contract" +"$znc" tests/display_list_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/display-list-contract" "$tmp/display-list-contract" +"$znc" tests/display_list_codec_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/display-list-codec-contract" +"$tmp/display-list-codec-contract" +"$znc" tests/cpu_raster_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/cpu-raster-contract" +"$tmp/cpu-raster-contract" -printf 'headless test: PASS (state, reconciliation, constraints, Flex, semantics, Talkback, and display lists)\n' +printf 'headless test: PASS (state, reconciliation, constraints, Flex, semantics, Talkback, display lists, and CPU raster)\n' From d57a2b275042ffd6b88b48697091edc06c58c497 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 05:47:28 -0700 Subject: [PATCH 010/103] Scope contract checks to Zagkit source --- tools/check-contracts.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tools/check-contracts.sh b/tools/check-contracts.sh index ec16e7f..5f4acb5 100755 --- a/tools/check-contracts.sh +++ b/tools/check-contracts.sh @@ -211,7 +211,13 @@ jq -e ' .current_runtime_truth."native-transport" == "unavailable" ' contracts/talkback-protocol.json >/dev/null || fail "Zagkit Talkback protocol contract is incomplete" -if [ -d ../zag/.git ]; then +if [ -d .toolchain/zag/.git ]; then + git -C .toolchain/zag cat-file -e "$compiler_commit^{commit}" 2>/dev/null \ + || fail "CI Zag checkout does not contain the pinned commit" + [ "$(git -C .toolchain/zag rev-parse HEAD)" = "$compiler_commit" ] \ + || fail "CI Zag checkout HEAD does not equal the pinned commit" + printf 'contract check: verified pinned Zag commit in CI checkout\n' +elif [ -d ../zag/.git ]; then git -C ../zag cat-file -e "$compiler_commit^{commit}" 2>/dev/null \ || fail "neighboring Zag repository does not contain the pinned commit" printf 'contract check: verified pinned Zag commit in neighboring checkout\n' @@ -227,7 +233,7 @@ for source_root in src packages platform; do fi done -find . -path './.git' -prune -o -name '*.md' -type f -print | sort | +find . \( -path './.git' -o -path './.toolchain' \) -prune -o -name '*.md' -type f -print | sort | while IFS= read -r markdown_file; do markdown_base=$(dirname "$markdown_file") grep -oE '\]\([^)]+\)' "$markdown_file" | From d31d0dac4693d69ae03114e5e500fbe0dd5e0a48 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 05:47:55 -0700 Subject: [PATCH 011/103] Avoid duplicate branch CI bootstraps --- .github/workflows/contracts.yml | 1 + tools/check-contracts.sh | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index b7e52c7..d589fc9 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -2,6 +2,7 @@ name: Repository contracts on: push: + branches: [main] pull_request: permissions: diff --git a/tools/check-contracts.sh b/tools/check-contracts.sh index 5f4acb5..5850ab5 100755 --- a/tools/check-contracts.sh +++ b/tools/check-contracts.sh @@ -32,6 +32,8 @@ done grep -q 'Run strict headless contracts' .github/workflows/contracts.yml \ || fail "CI no longer executes strict headless contracts" +grep -q 'branches: \[main\]' .github/workflows/contracts.yml \ + || fail "CI no longer limits duplicate push validation to main" grep -q 'contracts/toolchain.json' .github/workflows/contracts.yml \ || fail "CI no longer resolves its Zag revision from the toolchain contract" grep -q 'ZAG_BOOTSTRAP_MEMORY_GUARD=off .toolchain/zag/zag-poc/bootstrap.sh' .github/workflows/contracts.yml \ From 5fe6aee437279ac8e8c0e0523b1d3436a6e15352 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 06:04:26 -0700 Subject: [PATCH 012/103] Cache exact fixed-point Zag compiler in CI --- .github/workflows/contracts.yml | 18 +++++++++++++++--- README.md | 5 +++++ tools/check-contracts.sh | 4 +++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index d589fc9..4cba928 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -11,7 +11,7 @@ permissions: jobs: validate: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 30 steps: - name: Check out source uses: actions/checkout@v6 @@ -24,12 +24,24 @@ jobs: repository: Sylorlabs/zag ref: ${{ steps.toolchain.outputs.zag_sha }} path: .toolchain/zag + - name: Restore fixed-point Zag compiler + id: zag-cache + uses: actions/cache@v5 + with: + path: | + .toolchain/zag/zag-poc/znc + .toolchain/zag/zag-poc/zagd + key: zag-fixed-${{ runner.os }}-${{ steps.toolchain.outputs.zag_sha }} + - name: Bootstrap pinned Zag source + if: steps.zag-cache.outputs.cache-hit != 'true' + run: | + chmod +x .toolchain/zag/zag-poc/znc .toolchain/zag/zag-poc/bootstrap.sh + ZAG_BOOTSTRAP_MEMORY_GUARD=off .toolchain/zag/zag-poc/bootstrap.sh - name: Run strict headless contracts env: ZNC: ${{ github.workspace }}/.toolchain/zag/zag-poc/znc run: | - chmod +x "$ZNC" .toolchain/zag/zag-poc/bootstrap.sh - ZAG_BOOTSTRAP_MEMORY_GUARD=off .toolchain/zag/zag-poc/bootstrap.sh + chmod +x "$ZNC" ./tools/test-headless.sh - name: Validate product contracts run: ./tools/check-contracts.sh diff --git a/README.md b/README.md index df6cd58..725303f 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,11 @@ the same suite before validating repository metadata. The committed upstream seed is bootstrap authority, not evidence that it already contains later compiler-source fixes. +CI allows 30 minutes for a cold fixed-point rebuild and caches only `znc` and +`zagd` under the exact operating system and Zag source SHA. A source-revision +change cannot reuse an older compiler cache; warm runs still execute every +strict Zagkit contract. + ## Build order 1. Advance reusable compiler, ABI, concurrency, package, and platform features diff --git a/tools/check-contracts.sh b/tools/check-contracts.sh index 5850ab5..094e888 100755 --- a/tools/check-contracts.sh +++ b/tools/check-contracts.sh @@ -36,8 +36,10 @@ grep -q 'branches: \[main\]' .github/workflows/contracts.yml \ || fail "CI no longer limits duplicate push validation to main" grep -q 'contracts/toolchain.json' .github/workflows/contracts.yml \ || fail "CI no longer resolves its Zag revision from the toolchain contract" -grep -q 'ZAG_BOOTSTRAP_MEMORY_GUARD=off .toolchain/zag/zag-poc/bootstrap.sh' .github/workflows/contracts.yml \ +grep -q 'Bootstrap pinned Zag source' .github/workflows/contracts.yml \ || fail "CI no longer self-hosts the pinned Zag source before testing" +grep -q 'zag-fixed-${{ runner.os }}-${{ steps.toolchain.outputs.zag_sha }}' .github/workflows/contracts.yml \ + || fail "CI no longer caches fixed-point Zag by exact source revision" grep -q -- '--analyze-strict' tools/test-headless.sh \ || fail "headless tests no longer enforce strict Zag analysis" From 7632b179939ea98cb121e2cb594b997e3ec219e2 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 06:26:13 -0700 Subject: [PATCH 013/103] Implement transformed input routing foundation --- CHANGELOG.md | 2 + README.md | 7 ++ docs/architecture/README.md | 6 ++ src/input/hit_test.zag | 183 ++++++++++++++++++++++++++++++++++++ src/input/router.zag | 182 +++++++++++++++++++++++++++++++++++ tests/input_contract.zag | 125 ++++++++++++++++++++++++ tools/test-headless.sh | 4 +- 7 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 src/input/hit_test.zag create mode 100644 src/input/router.zag create mode 100644 tests/input_contract.zag diff --git a/CHANGELOG.md b/CHANGELOG.md index 9144dda..aa14c60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ include a generated capability record and exact Zag compiler revision. axis-aligned transforms, fractional coverage, and source-over alpha. - Strict executable headless contracts in CI using the exact pinned Zag revision rather than an ambient compiler. +- Transformed hit testing and deterministic pointer routing with clipping, + z-order, capture, focus, stale-target recovery, and ordered evidence. ## 0.1.0-experimental.0 (unreleased) diff --git a/README.md b/README.md index 725303f..aed260b 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,13 @@ deterministic source-over alpha into owned RGBA8 surfaces. Unsupported paths, images, glyphs, strokes, skew, layers, and effects fail at the exact operation; this subset is not yet the complete CPU renderer required by Milestone 2. +The first input slice resolves full affine transforms back to local coordinates, +honors local clips and z-order, rejects singular or malformed hit nodes, and +routes pointer phases through explicit capture and focus truth. Capture loss, +pointer-up release, stale targets, misses, and invalid pointers remain visible +in one ordered event stream. Keyboard, touch arbitration, wheel payloads, +gestures, and replay remain open. + Run the deterministic headless foundation test with: ```sh diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 6117e54..53ee3fe 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -59,3 +59,9 @@ The CPU oracle begins with deterministic RGBA8 rectangle rasterization from fractional edge coverage, and source-over alpha are integer-only. Every unsupported display operation fails at its exact index instead of silently degrading or claiming a visual result. + +The experimental input router consumes a parallel retained `HitTree`. It uses +integer affine inversion for local coordinates, resolves local clips and +z-order deterministically, and keeps pointer capture and focus explicit. +Accepted, missed, captured, cancelled, and invalid events share one ordered +evidence stream; platform input adaptation and replay remain open. diff --git a/src/input/hit_test.zag b/src/input/hit_test.zag new file mode 100644 index 0000000..ca8a346 --- /dev/null +++ b/src/input/hit_test.zag @@ -0,0 +1,183 @@ +@import("std:list") +@import("../core/geometry.zag") +@import("../core/view_contract.zag") +@import("../render/display_list.zag") + +enum HitTreeError { + none, + duplicate_id, + missing_parent, + invalid_geometry, + invalid_transform, + singular_transform, +} + +struct HitNode { + id: NodeKey, + parent: NodeKey, + bounds: Rect, + clip: Rect, + has_clip: i32, + transform: Transform, + z_order: i64, + enabled: i32, + focusable: i32, +} + +struct HitTree { + nodes: ArrayList[HitNode], + revision: i64, + last_error: HitTreeError, + error_node: NodeKey, +} + +struct HitResult { + found: i32, + id: NodeKey, + node_index: i32, + local_x: i64, + local_y: i64, + z_order: i64, +} + +fn hit_root_key() NodeKey { return node_key(0 - 1); } + +fn hit_node(id: NodeKey, parent: NodeKey, bounds: Rect) HitNode { + return HitNode{ + .id = id, + .parent = parent, + .bounds = bounds, + .clip = bounds, + .has_clip = 0, + .transform = transform_identity(), + .z_order = 0, + .enabled = 1, + .focusable = 0, + }; +} + +fn hit_tree_make() HitTree { + return HitTree{ + .nodes = make[HitNode](16), + .revision = 0, + .last_error = HitTreeError.none, + .error_node = hit_root_key(), + }; +} + +fn hit_tree_free(tree: *HitTree) void { + free[HitNode](&tree.*.nodes); +} + +fn hit_tree_find_index(tree: HitTree, id: NodeKey) i32 { + let i: i32 = tree.nodes.len - 1; + while (i >= 0) { + if (node_key_equal(tree.nodes.data[i].id, id) == 1) { return i; } + i = i - 1; + } + return 0 - 1; +} + +fn hit_transform_values_valid(transform: Transform) i32 { + let limit: i64 = 4096 * unit_scale(); + if (transform.scale_x < 0 - limit || transform.scale_x > limit || + transform.scale_y < 0 - limit || transform.scale_y > limit || + transform.skew_x < 0 - limit || transform.skew_x > limit || + transform.skew_y < 0 - limit || transform.skew_y > limit || + transform.translate_x < -1000000000 || transform.translate_x > 1000000000 || + transform.translate_y < -1000000000 || transform.translate_y > 1000000000) { return 0; } + return 1; +} + +fn hit_geometry_valid(value: Rect) i32 { + let limit: i64 = 1000000000; + return (value.x >= 0 - limit && value.x <= limit && + value.y >= 0 - limit && value.y <= limit && + value.width > 0 && value.width <= limit && + value.height > 0 && value.height <= limit) as i32; +} + +fn hit_tree_fail(tree: *HitTree, error: HitTreeError, id: NodeKey) HitTreeError { + tree.*.last_error = error; + tree.*.error_node = id; + return error; +} + +fn hit_tree_add(tree: *HitTree, node: HitNode) HitTreeError { + if (hit_tree_find_index(tree.*, node.id) >= 0) { + return hit_tree_fail(tree, HitTreeError.duplicate_id, node.id); + } + if (node_key_equal(node.parent, hit_root_key()) == 0 && + hit_tree_find_index(tree.*, node.parent) < 0) { + return hit_tree_fail(tree, HitTreeError.missing_parent, node.id); + } + if (hit_geometry_valid(node.bounds) == 0 || + (node.has_clip != 0 && hit_geometry_valid(node.clip) == 0)) { + return hit_tree_fail(tree, HitTreeError.invalid_geometry, node.id); + } + if (hit_transform_values_valid(node.transform) == 0) { + return hit_tree_fail(tree, HitTreeError.invalid_transform, node.id); + } + let determinant: i64 = node.transform.scale_x * node.transform.scale_y - + node.transform.skew_x * node.transform.skew_y; + if (determinant == 0) { + return hit_tree_fail(tree, HitTreeError.singular_transform, node.id); + } + push[HitNode](&tree.*.nodes, node); + tree.*.revision = tree.*.revision + 1; + tree.*.last_error = HitTreeError.none; + tree.*.error_node = hit_root_key(); + return HitTreeError.none; +} + +fn hit_empty_result() HitResult { + return HitResult{ + .found = 0, + .id = hit_root_key(), + .node_index = 0 - 1, + .local_x = 0, + .local_y = 0, + .z_order = 0, + }; +} + +fn hit_node_local(node: HitNode, world_x: i64, world_y: i64) HitResult { + if (world_x < -1000000000 || world_x > 1000000000 || + world_y < -1000000000 || world_y > 1000000000) { return hit_empty_result(); } + let dx: i64 = world_x - node.transform.translate_x; + let dy: i64 = world_y - node.transform.translate_y; + let determinant: i64 = node.transform.scale_x * node.transform.scale_y - + node.transform.skew_x * node.transform.skew_y; + if (determinant == 0) { return hit_empty_result(); } + let local_x: i64 = ((dx * node.transform.scale_y - dy * node.transform.skew_x) * unit_scale()) / determinant; + let local_y: i64 = ((dy * node.transform.scale_x - dx * node.transform.skew_y) * unit_scale()) / determinant; + return HitResult{ + .found = 1, + .id = node.id, + .node_index = 0 - 1, + .local_x = local_x, + .local_y = local_y, + .z_order = node.z_order, + }; +} + +fn hit_test(tree: HitTree, world_x: i64, world_y: i64) HitResult { + if (world_x < -1000000000 || world_x > 1000000000 || + world_y < -1000000000 || world_y > 1000000000) { return hit_empty_result(); } + let best: HitResult = hit_empty_result(); + let i: i32 = tree.nodes.len - 1; + while (i >= 0) { + let node: HitNode = tree.nodes.data[i]; + if (node.enabled != 0) { + let candidate: HitResult = hit_node_local(node, world_x, world_y); + if (candidate.found != 0 && rect_contains(node.bounds, candidate.local_x, candidate.local_y) != 0 && + (node.has_clip == 0 || rect_contains(node.clip, candidate.local_x, candidate.local_y) != 0) && + (best.found == 0 || node.z_order > best.z_order)) { + best = candidate; + best.node_index = i; + } + } + i = i - 1; + } + return best; +} diff --git a/src/input/router.zag b/src/input/router.zag new file mode 100644 index 0000000..9384882 --- /dev/null +++ b/src/input/router.zag @@ -0,0 +1,182 @@ +@import("std:list") +@import("hit_test.zag") + +enum PointerPhase { down, move, up, cancel, wheel } +enum RouteStatus { accepted, invalid_pointer } + +struct PointerEvent { + pointer_id: i64, + phase: PointerPhase, + x: i64, + y: i64, + buttons: i64, +} + +struct PointerCapture { + pointer_id: i64, + target: NodeKey, +} + +struct RoutedEvent { + sequence: i64, + pointer_id: i64, + phase: PointerPhase, + target: NodeKey, + local_x: i64, + local_y: i64, + captured: i32, + status: RouteStatus, +} + +struct RouteResult { + status: RouteStatus, + hit: HitResult, + sequence: i64, + captured: i32, +} + +struct InputRouter { + captures: ArrayList[PointerCapture], + active_pointers: ArrayList[i64], + events: ArrayList[RoutedEvent], + focus: NodeKey, + next_sequence: i64, +} + +fn pointer_event(pointer_id: i64, phase: PointerPhase, x: i64, y: i64) PointerEvent { + return PointerEvent{ .pointer_id = pointer_id, .phase = phase, .x = x, .y = y, .buttons = 0 }; +} + +fn input_router_make() InputRouter { + return InputRouter{ + .captures = make[PointerCapture](4), + .active_pointers = make[i64](4), + .events = make[RoutedEvent](32), + .focus = hit_root_key(), + .next_sequence = 1, + }; +} + +fn input_router_free(router: *InputRouter) void { + free[PointerCapture](&router.*.captures); + free[i64](&router.*.active_pointers); + free[RoutedEvent](&router.*.events); +} + +fn input_active_index(router: InputRouter, pointer_id: i64) i32 { + let i: i32 = router.active_pointers.len - 1; + while (i >= 0) { + if (router.active_pointers.data[i] == pointer_id) { return i; } + i = i - 1; + } + return 0 - 1; +} + +fn input_set_active(router: *InputRouter, pointer_id: i64) void { + if (input_active_index(router.*, pointer_id) < 0) { push[i64](&router.*.active_pointers, pointer_id); } +} + +fn input_clear_active(router: *InputRouter, pointer_id: i64) void { + let index: i32 = input_active_index(router.*, pointer_id); + if (index < 0) { return; } + let i: i32 = index; + while (i + 1 < router.*.active_pointers.len) { + router.*.active_pointers.data[i] = router.*.active_pointers.data[i + 1]; + i = i + 1; + } + router.*.active_pointers.len = router.*.active_pointers.len - 1; +} + +fn input_capture_index(router: InputRouter, pointer_id: i64) i32 { + let i: i32 = router.captures.len - 1; + while (i >= 0) { + if (router.captures.data[i].pointer_id == pointer_id) { return i; } + i = i - 1; + } + return 0 - 1; +} + +fn input_release_capture(router: *InputRouter, pointer_id: i64) i32 { + let index: i32 = input_capture_index(router.*, pointer_id); + if (index < 0) { return 0; } + let i: i32 = index; + while (i + 1 < router.*.captures.len) { + router.*.captures.data[i] = router.*.captures.data[i + 1]; + i = i + 1; + } + router.*.captures.len = router.*.captures.len - 1; + return 1; +} + +fn input_request_capture(router: *InputRouter, tree: HitTree, pointer_id: i64, target: NodeKey) i32 { + if (pointer_id < 0 || input_active_index(router.*, pointer_id) < 0) { return 0; } + let node_index: i32 = hit_tree_find_index(tree, target); + if (node_index < 0 || tree.nodes.data[node_index].enabled == 0) { return 0; } + let existing: i32 = input_capture_index(router.*, pointer_id); + if (existing >= 0) { + router.*.captures.data[existing].target = target; + return 1; + } + push[PointerCapture](&router.*.captures, PointerCapture{ .pointer_id = pointer_id, .target = target }); + return 1; +} + +fn input_request_focus(router: *InputRouter, tree: HitTree, target: NodeKey) i32 { + let node_index: i32 = hit_tree_find_index(tree, target); + if (node_index < 0 || tree.nodes.data[node_index].enabled == 0 || + tree.nodes.data[node_index].focusable == 0) { return 0; } + router.*.focus = target; + return 1; +} + +fn input_route_record(router: *InputRouter, event: PointerEvent, status: RouteStatus, hit: HitResult, captured: i32) RouteResult { + let sequence: i64 = router.*.next_sequence; + router.*.next_sequence = router.*.next_sequence + 1; + push[RoutedEvent](&router.*.events, RoutedEvent{ + .sequence = sequence, + .pointer_id = event.pointer_id, + .phase = event.phase, + .target = hit.id, + .local_x = hit.local_x, + .local_y = hit.local_y, + .captured = captured, + .status = status, + }); + return RouteResult{ .status = status, .hit = hit, .sequence = sequence, .captured = captured }; +} + +fn input_route_pointer(router: *InputRouter, tree: HitTree, event: PointerEvent) RouteResult { + if (event.pointer_id < 0 || event.x < -1000000000 || event.x > 1000000000 || + event.y < -1000000000 || event.y > 1000000000) { + return input_route_record(router, event, RouteStatus.invalid_pointer, hit_empty_result(), 0); + } + + if (event.phase == PointerPhase.down) { input_set_active(router, event.pointer_id); } + + let hit: HitResult = hit_empty_result(); + let captured: i32 = 0; + let capture_index: i32 = input_capture_index(router.*, event.pointer_id); + if (capture_index >= 0) { + let target_index: i32 = hit_tree_find_index(tree, router.*.captures.data[capture_index].target); + if (target_index >= 0 && tree.nodes.data[target_index].enabled != 0) { + hit = hit_node_local(tree.nodes.data[target_index], event.x, event.y); + hit.node_index = target_index; + captured = 1; + } else { + _ = input_release_capture(router, event.pointer_id); + } + } + if (captured == 0 && event.phase != PointerPhase.cancel) { + hit = hit_test(tree, event.x, event.y); + } + if (event.phase == PointerPhase.down && hit.found != 0 && + tree.nodes.data[hit.node_index].focusable != 0) { + router.*.focus = hit.id; + } + let result: RouteResult = input_route_record(router, event, RouteStatus.accepted, hit, captured); + if (event.phase == PointerPhase.up || event.phase == PointerPhase.cancel) { + _ = input_release_capture(router, event.pointer_id); + input_clear_active(router, event.pointer_id); + } + return result; +} diff --git a/tests/input_contract.zag b/tests/input_contract.zag new file mode 100644 index 0000000..c31a7a1 --- /dev/null +++ b/tests/input_contract.zag @@ -0,0 +1,125 @@ +@import("../src/input/router.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn basic_tree() HitTree { + let tree: HitTree = hit_tree_make(); + let root: HitNode = hit_node(node_key(1), hit_root_key(), rect(0, 0, 200, 200)); + root.z_order = 0; + _ = hit_tree_add(&tree, root); + let bottom: HitNode = hit_node(node_key(2), node_key(1), rect(0, 0, 100, 100)); + bottom.z_order = 1; + bottom.focusable = 1; + _ = hit_tree_add(&tree, bottom); + let top: HitNode = hit_node(node_key(3), node_key(1), rect(0, 0, 100, 100)); + top.z_order = 2; + top.focusable = 1; + top.has_clip = 1; + top.clip = rect(10, 10, 50, 50); + _ = hit_tree_add(&tree, top); + return tree; +} + +fn hit_contract(state: *TestState) void { + let tree: HitTree = basic_tree(); + let overlap: HitResult = hit_test(tree, 20, 20); + let clipped: HitResult = hit_test(tree, 5, 5); + expect(state, overlap.found == 1 && overlap.id.value == 3, "highest z-order clipped node wins overlap"); + expect(state, clipped.found == 1 && clipped.id.value == 2, "local clip excludes the top node without hiding lower content"); + + let rotated: HitNode = hit_node(node_key(4), node_key(1), rect(0, 0, 64, 64)); + rotated.z_order = 5; + rotated.transform = Transform{ + .scale_x = 0, .skew_x = 0 - unit_scale(), + .skew_y = unit_scale(), .scale_y = 0, + .translate_x = 128, .translate_y = 0, + }; + _ = hit_tree_add(&tree, rotated); + let transformed: HitResult = hit_test(tree, 96, 32); + expect(state, transformed.id.value == 4 && transformed.local_x == 32 && transformed.local_y == 32, "affine inverse returns exact local coordinates for rotated content"); + hit_tree_free(&tree); +} + +fn validation_contract(state: *TestState) void { + let tree: HitTree = hit_tree_make(); + let root: HitNode = hit_node(node_key(10), hit_root_key(), rect(0, 0, 10, 10)); + _ = hit_tree_add(&tree, root); + expect(state, hit_tree_add(&tree, root) == HitTreeError.duplicate_id && tree.nodes.len == 1, "duplicate hit IDs fail before mutation"); + let orphan: HitNode = hit_node(node_key(11), node_key(99), rect(0, 0, 10, 10)); + expect(state, hit_tree_add(&tree, orphan) == HitTreeError.missing_parent, "missing hit parent fails visibly"); + let singular: HitNode = hit_node(node_key(12), node_key(10), rect(0, 0, 10, 10)); + singular.transform.scale_x = 0; + expect(state, hit_tree_add(&tree, singular) == HitTreeError.singular_transform, "singular transforms never enter the hit tree"); + let extreme: HitNode = hit_node(node_key(13), node_key(10), rect(0, 0, 10, 10)); + extreme.transform.translate_x = 1000000001; + expect(state, hit_tree_add(&tree, extreme) == HitTreeError.invalid_transform, "unsafe transform translation never enters the hit tree"); + expect(state, hit_test(tree, 1000000001, 0).found == 0, "unsafe direct hit coordinates fail closed before affine arithmetic"); + hit_tree_free(&tree); +} + +fn routing_contract(state: *TestState) void { + let tree: HitTree = basic_tree(); + let router: InputRouter = input_router_make(); + expect(state, input_request_capture(&router, tree, 7, node_key(3)) == 0, "capture cannot target a pointer before its down phase"); + let down: RouteResult = input_route_pointer(&router, tree, pointer_event(7, PointerPhase.down, 20, 20)); + expect(state, down.hit.id.value == 3 && router.focus.value == 3 && down.captured == 0, "pointer down targets top node and advances focus"); + expect(state, input_request_capture(&router, tree, 7, node_key(3)) == 1, "enabled node can capture an active pointer identity"); + let outside: RouteResult = input_route_pointer(&router, tree, pointer_event(7, PointerPhase.move, 500, 500)); + expect(state, outside.hit.id.value == 3 && outside.captured == 1 && outside.hit.local_x == 500, "captured move remains ID-routed outside bounds with local coordinates"); + let up: RouteResult = input_route_pointer(&router, tree, pointer_event(7, PointerPhase.up, 500, 500)); + expect(state, up.captured == 1 && input_capture_index(router, 7) < 0, "pointer up routes to capture then releases it"); + let after: RouteResult = input_route_pointer(&router, tree, pointer_event(7, PointerPhase.move, 500, 500)); + expect(state, after.hit.found == 0 && after.captured == 0, "uncaptured outside move has no phantom target"); + expect(state, router.events.len == 4 && router.events.data[3].sequence == 4, "input evidence is one monotonic ordered stream"); + + let invalid: RouteResult = input_route_pointer(&router, tree, pointer_event(0 - 1, PointerPhase.down, 0, 0)); + expect(state, invalid.status == RouteStatus.invalid_pointer && invalid.sequence == 5, "invalid pointers fail visibly without escaping the event log"); + expect(state, input_request_focus(&router, tree, node_key(1)) == 0 && input_request_focus(&router, tree, node_key(2)) == 1, "focus requests enforce enabled focusable targets"); + input_router_free(&router); + hit_tree_free(&tree); +} + +fn stale_capture_contract(state: *TestState) void { + let tree: HitTree = basic_tree(); + let router: InputRouter = input_router_make(); + _ = input_route_pointer(&router, tree, pointer_event(9, PointerPhase.down, 20, 20)); + _ = input_request_capture(&router, tree, 9, node_key(3)); + tree.nodes.data[2].enabled = 0; + let result: RouteResult = input_route_pointer(&router, tree, pointer_event(9, PointerPhase.move, 20, 20)); + expect(state, result.captured == 0 && result.hit.id.value == 2 && input_capture_index(router, 9) < 0, "disabled capture target releases and reroutes from current truth"); + _ = input_route_pointer(&router, tree, pointer_event(10, PointerPhase.down, 20, 20)); + _ = input_request_capture(&router, tree, 10, node_key(2)); + let cancelled: RouteResult = input_route_pointer(&router, tree, pointer_event(10, PointerPhase.cancel, 500, 500)); + let after_cancel: RouteResult = input_route_pointer(&router, tree, pointer_event(10, PointerPhase.move, 500, 500)); + expect(state, cancelled.captured == 1 && cancelled.hit.id.value == 2 && + input_capture_index(router, 10) < 0 && input_active_index(router, 10) < 0 && + after_cancel.hit.found == 0, "cancel routes once to capture then clears active pointer state"); + input_router_free(&router); + hit_tree_free(&tree); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + hit_contract(&state); + validation_contract(&state); + routing_contract(&state); + stale_capture_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Input contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); + _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 005c41f..e2222f4 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -28,5 +28,7 @@ cd "$root" "$tmp/display-list-codec-contract" "$znc" tests/cpu_raster_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/cpu-raster-contract" "$tmp/cpu-raster-contract" +"$znc" tests/input_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/input-contract" +"$tmp/input-contract" -printf 'headless test: PASS (state, reconciliation, constraints, Flex, semantics, Talkback, display lists, and CPU raster)\n' +printf 'headless test: PASS (state, reconciliation, constraints, Flex, semantics, Talkback, display lists, CPU raster, and input)\n' From a26c5b377804cada77cb34d0381c7a38056313ae Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 06:36:29 -0700 Subject: [PATCH 014/103] Implement deterministic replay foundation --- CHANGELOG.md | 3 + README.md | 22 +- contracts/platforms.json | 10 +- docs/architecture/README.md | 13 +- src/replay/replay.zag | 500 ++++++++++++++++++++++++++++++++++++ tests/replay_contract.zag | 175 +++++++++++++ tools/test-headless.sh | 4 +- 7 files changed, 712 insertions(+), 15 deletions(-) create mode 100644 src/replay/replay.zag create mode 100644 tests/replay_contract.zag diff --git a/CHANGELOG.md b/CHANGELOG.md index aa14c60..f135e64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,9 @@ include a generated capability record and exact Zag compiler revision. revision rather than an ambient compiler. - Transformed hit testing and deterministic pointer routing with clipping, z-order, capture, focus, stale-target recovery, and ordered evidence. +- A sealed deterministic replay tape and conformance executor covering exact + state revisions, pointer input, monotonic time, backend activation, loss, + recovery, and regenerated semantics, Flex, display-list, and CPU identities. ## 0.1.0-experimental.0 (unreleased) diff --git a/README.md b/README.md index aed260b..886153b 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ modern materials and asset fidelity, and a complete PrismStudio UI replacement. This repository is at **0.1.0-experimental.0**. It currently contains the accepted product contract, executable Milestone 0 checks, and the first deterministic state, keyed reconciliation, geometry, Flex, semantics, Talkback, -and display-list slices. It does not yet contain a usable renderer, window shell, component -library, or supported platform backend. Nothing in this repository is a Zagkit -1.0 release. +display-list, CPU-oracle, input, and replay slices. It does not yet contain a +usable renderer, window shell, component library, or supported platform +backend. Nothing in this repository is a Zagkit 1.0 release. ## What Zagkit owns @@ -41,7 +41,7 @@ is normative in [DEPENDENCIES.md](DEPENDENCIES.md). | Product and architecture contract | accepted | [RFC index](docs/rfcs/README.md) | | Compiler dependency | pinned, prerequisites incomplete | [toolchain lock](contracts/toolchain.json) | | Platform shells | unavailable | [support matrix](SUPPORT.md) | -| Headless core | experimental state, reconciliation, geometry, Flex, semantics, Talkback, and display lists | [headless test](tools/test-headless.sh) | +| Headless core | experimental state, reconciliation, geometry, Flex, semantics, Talkback, display lists, CPU raster, input, and replay | [headless test](tools/test-headless.sh) | | Components and visual language | inventory only, visual review pending | [component inventory](contracts/components.json) | | Flex and Zagkit Talkback | Flex foundation and in-process ID-first Talkback dispatch executing; native transport remains unavailable | [Talkback contract](docs/automation/talkback.md) | | Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | @@ -60,8 +60,8 @@ The compiled headless contract currently provides revisioned `State`, action-producing `Binding`, inherited integer environment values, exact per-node state-read records, fail-visible keyed reconciliation, deterministic fixed-point geometry, and single-line Flex. These APIs are experimental. Typed -environment values, reconciliation cancellation, child ownership, replay, -intrinsic measurement, wrapping, grid, overlay, and breakpoints remain open; +environment values, reconciliation cancellation, child ownership, replay +serialization, intrinsic measurement, wrapping, grid, overlay, and breakpoints remain open; the corresponding Milestone 2 checklist items are not complete. The semantics slice owns copied names and values, stable keys, explicit action @@ -100,7 +100,15 @@ honors local clips and z-order, rejects singular or malformed hit nodes, and routes pointer phases through explicit capture and focus truth. Capture loss, pointer-up release, stale targets, misses, and invalid pointers remain visible in one ordered event stream. Keyboard, touch arbitration, wheel payloads, -gestures, and replay remain open. +gestures, and platform input adaptation remain open. + +The first replay slice owns a sealed ordered tape for exact state revisions, +pointer phases, monotonic time, backend activation, device loss, and recovery. +Executing the same tape regenerates identical semantic, Flex, display-list, and +CPU identities; raw mutation, stale revisions, clock regression, and invalid +backend transitions fail before they can become evidence. The current scene is +an executable conformance reference, not yet a general application callback +boundary or a versioned replay-file format, so `G2-REPLAY` remains open. Run the deterministic headless foundation test with: diff --git a/contracts/platforms.json b/contracts/platforms.json index 5fb9911..8b42c9e 100644 --- a/contracts/platforms.json +++ b/contracts/platforms.json @@ -24,7 +24,7 @@ "public_channel": "experimental", "capabilities": [ {"id":"platform_shell","state":"unavailable","reason":"Wayland and X11 shells are not implemented.","evidence":[]}, - {"id":"cpu_renderer","state":"unavailable","reason":"The deterministic CPU renderer is not implemented.","evidence":[]}, + {"id":"cpu_renderer","state":"unavailable","reason":"A headless rectangle CPU-oracle subset exists, but full display-list coverage and Linux surface presentation are not implemented.","evidence":["tests/cpu_raster_contract.zag","tests/replay_contract.zag"]}, {"id":"gpu_transport","state":"unavailable","reason":"No Zagkit Linux GPU transport exists or has device evidence.","evidence":[]}, {"id":"text_input","state":"unavailable","reason":"No Linux IME bridge or editing engine exists.","evidence":[]}, {"id":"accessibility","state":"unavailable","reason":"No AT-SPI adapter exists or has assistive technology evidence.","evidence":[]}, @@ -43,7 +43,7 @@ "public_channel": "experimental", "capabilities": [ {"id":"platform_shell","state":"unavailable","reason":"The AppKit shell and Darwin target are not implemented.","evidence":[]}, - {"id":"cpu_renderer","state":"unavailable","reason":"The deterministic CPU renderer is not implemented.","evidence":[]}, + {"id":"cpu_renderer","state":"unavailable","reason":"A platform-independent headless rectangle CPU-oracle subset exists, but full display-list coverage and macOS presentation are not implemented.","evidence":["tests/cpu_raster_contract.zag","tests/replay_contract.zag"]}, {"id":"gpu_transport","state":"unavailable","reason":"No Zagkit Metal transport exists or has device evidence.","evidence":[]}, {"id":"text_input","state":"unavailable","reason":"No NSTextInputClient bridge or editing engine exists.","evidence":[]}, {"id":"accessibility","state":"unavailable","reason":"No NSAccessibility adapter exists or has VoiceOver evidence.","evidence":[]}, @@ -62,7 +62,7 @@ "public_channel": "experimental", "capabilities": [ {"id":"platform_shell","state":"unavailable","reason":"The Win32 shell and Windows target are not implemented.","evidence":[]}, - {"id":"cpu_renderer","state":"unavailable","reason":"The deterministic CPU renderer is not implemented.","evidence":[]}, + {"id":"cpu_renderer","state":"unavailable","reason":"A platform-independent headless rectangle CPU-oracle subset exists, but full display-list coverage and Windows presentation are not implemented.","evidence":["tests/cpu_raster_contract.zag","tests/replay_contract.zag"]}, {"id":"gpu_transport","state":"unavailable","reason":"No Zagkit D3D12 transport exists or has device evidence.","evidence":[]}, {"id":"text_input","state":"unavailable","reason":"No Core Text input bridge or editing engine exists.","evidence":[]}, {"id":"accessibility","state":"unavailable","reason":"No UI Automation adapter exists or has Narrator evidence.","evidence":[]}, @@ -81,7 +81,7 @@ "public_channel": "experimental", "capabilities": [ {"id":"platform_shell","state":"unavailable","reason":"The UIKit shell and iOS target are not implemented.","evidence":[]}, - {"id":"cpu_renderer","state":"unavailable","reason":"The deterministic CPU renderer is not implemented.","evidence":[]}, + {"id":"cpu_renderer","state":"unavailable","reason":"A platform-independent headless rectangle CPU-oracle subset exists, but full display-list coverage and iOS presentation are not implemented.","evidence":["tests/cpu_raster_contract.zag","tests/replay_contract.zag"]}, {"id":"gpu_transport","state":"unavailable","reason":"No Zagkit mobile Metal transport exists or has device evidence.","evidence":[]}, {"id":"text_input","state":"unavailable","reason":"No UIKit text input bridge or editing engine exists.","evidence":[]}, {"id":"accessibility","state":"unavailable","reason":"No UIKit accessibility adapter exists or has mobile VoiceOver evidence.","evidence":[]}, @@ -100,7 +100,7 @@ "public_channel": "experimental", "capabilities": [ {"id":"platform_shell","state":"unavailable","reason":"The Android shell and target are not implemented.","evidence":[]}, - {"id":"cpu_renderer","state":"unavailable","reason":"The deterministic CPU renderer is not implemented.","evidence":[]}, + {"id":"cpu_renderer","state":"unavailable","reason":"A platform-independent headless rectangle CPU-oracle subset exists, but full display-list coverage and Android presentation are not implemented.","evidence":["tests/cpu_raster_contract.zag","tests/replay_contract.zag"]}, {"id":"gpu_transport","state":"unavailable","reason":"No Zagkit Android GPU transport exists or has device evidence.","evidence":[]}, {"id":"text_input","state":"unavailable","reason":"No Android IME bridge or editing engine exists.","evidence":[]}, {"id":"accessibility","state":"unavailable","reason":"No Android accessibility adapter exists or has TalkBack evidence.","evidence":[]}, diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 53ee3fe..7180ec9 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -32,7 +32,7 @@ The first experimental compiled slice now fixes the initial Zag shapes for invalidation reports the exact read and revision edge; keyed reconciliation preserves retained identity through reorder and fails visibly on duplicate keys. Ownership, threading, serialization, typed environment values, -cancellation, and deterministic replay are still open contracts rather than +cancellation and replay serialization are still open contracts rather than stable API. The parallel experimental `SemanticsTree` retains owned names and values plus @@ -64,4 +64,13 @@ The experimental input router consumes a parallel retained `HitTree`. It uses integer affine inversion for local coordinates, resolves local clips and z-order deterministically, and keeps pointer capture and focus explicit. Accepted, missed, captured, cancelled, and invalid events share one ordered -evidence stream; platform input adaptation and replay remain open. +evidence stream; platform input adaptation remains open. + +The first deterministic replay executor consumes an immutable ordered tape of +state revisions, pointer events, monotonic clock samples, backend activation, +loss, and recovery. It rejects stale or impossible transitions at their exact +event index, then rebuilds a conformance scene through the real semantics, +Flex, display-list, and CPU-oracle paths. A repeated tape must produce identical +subsystem and aggregate hashes. A general application callback contract, +versioned tape codec, resource capture, and platform lifecycle integration are +still open. diff --git a/src/replay/replay.zag b/src/replay/replay.zag new file mode 100644 index 0000000..789a4de --- /dev/null +++ b/src/replay/replay.zag @@ -0,0 +1,500 @@ +@import("std:list") +@import("../core/state.zag") +@import("../input/router.zag") +@import("../layout/flex.zag") +@import("../semantics/semantics.zag") +@import("../render/cpu_raster.zag") + +enum ReplayEventKind { + state_i64, + pointer, + clock, + backend_activate, + backend_loss, + backend_recover, +} + +enum ReplayBackendState { unavailable, active, lost } + +enum ReplayError { + none, + sealed, + not_sealed, + content_mutated, + invalid_event, + stale_state_revision, + clock_regression, + backend_transition, + input_rejected, + scene_error, +} + +struct ReplayEvent { + sequence: i64, + kind: ReplayEventKind, + a: i64, + b: i64, + c: i64, + d: i64, + e: i64, + f: i64, +} + +struct ReplayTape { + events: ArrayList[ReplayEvent], + sealed: i32, + revision: i64, + content_hash: i64, + last_error: ReplayError, + error_index: i32, +} + +struct ReplayResult { + error: ReplayError, + error_event: i32, + events_applied: i64, + state_value: i64, + state_revision: i64, + clock: i64, + backend_id: i64, + backend_generation: i64, + backend_state: ReplayBackendState, + input_hash: i64, + semantics_hash: i64, + layout_hash: i64, + display_hash: i64, + cpu_hash: i64, + output_hash: i64, +} + +fn replay_event(kind: ReplayEventKind) ReplayEvent { + return ReplayEvent{ + .sequence = 0, + .kind = kind, + .a = 0, + .b = 0, + .c = 0, + .d = 0, + .e = 0, + .f = 0, + }; +} + +fn replay_state_event(state_id: i64, expected_revision: i64, value: i64) ReplayEvent { + let event: ReplayEvent = replay_event(ReplayEventKind.state_i64); + event.a = state_id; + event.b = expected_revision; + event.c = value; + return event; +} + +fn replay_pointer_event(pointer_id: i64, phase: PointerPhase, x: i64, y: i64) ReplayEvent { + let event: ReplayEvent = replay_event(ReplayEventKind.pointer); + event.a = pointer_id; + event.b = phase as i64; + event.c = x; + event.d = y; + return event; +} + +fn replay_clock_event(time: i64) ReplayEvent { + let event: ReplayEvent = replay_event(ReplayEventKind.clock); + event.a = time; + return event; +} + +fn replay_backend_activate_event(backend_id: i64) ReplayEvent { + let event: ReplayEvent = replay_event(ReplayEventKind.backend_activate); + event.a = backend_id; + return event; +} + +fn replay_backend_loss_event(backend_id: i64) ReplayEvent { + let event: ReplayEvent = replay_event(ReplayEventKind.backend_loss); + event.a = backend_id; + return event; +} + +fn replay_backend_recover_event(backend_id: i64) ReplayEvent { + let event: ReplayEvent = replay_event(ReplayEventKind.backend_recover); + event.a = backend_id; + return event; +} + +fn replay_tape_make() ReplayTape { + return ReplayTape{ + .events = make[ReplayEvent](32), + .sealed = 0, + .revision = 0, + .content_hash = 17, + .last_error = ReplayError.none, + .error_index = 0 - 1, + }; +} + +fn replay_tape_free(tape: *ReplayTape) void { + free[ReplayEvent](&tape.*.events); +} + +fn replay_hash_event(hash: i64, event: ReplayEvent) i64 { + let result: i64 = display_mix(hash, event.sequence); + result = display_mix(result, event.kind as i64); + result = display_mix(result, event.a); + result = display_mix(result, event.b); + result = display_mix(result, event.c); + result = display_mix(result, event.d); + result = display_mix(result, event.e); + return display_mix(result, event.f); +} + +fn replay_event_valid(event: ReplayEvent) i32 { + if (event.kind == ReplayEventKind.state_i64) { + return (event.a > 0 && event.b >= 0 && event.c >= -1000000 && event.c <= 1000000 && + event.d == 0 && event.e == 0 && event.f == 0) as i32; + } + if (event.kind == ReplayEventKind.pointer) { + return (event.a >= 0 && event.b >= PointerPhase.down as i64 && event.b <= PointerPhase.wheel as i64 && + event.c >= -1000000000 && event.c <= 1000000000 && + event.d >= -1000000000 && event.d <= 1000000000 && event.e == 0 && event.f == 0) as i32; + } + if (event.kind == ReplayEventKind.clock) { + return (event.a >= 0 && event.b == 0 && event.c == 0 && event.d == 0 && + event.e == 0 && event.f == 0) as i32; + } + if (event.kind == ReplayEventKind.backend_activate || + event.kind == ReplayEventKind.backend_loss || + event.kind == ReplayEventKind.backend_recover) { + return (event.a > 0 && event.b == 0 && event.c == 0 && event.d == 0 && + event.e == 0 && event.f == 0) as i32; + } + return 0; +} + +fn replay_tape_fail(tape: *ReplayTape, error: ReplayError) ReplayError { + tape.*.last_error = error; + tape.*.error_index = tape.*.events.len; + return error; +} + +fn replay_tape_push(tape: *ReplayTape, raw_event: ReplayEvent) ReplayError { + if (tape.*.sealed != 0) { return replay_tape_fail(tape, ReplayError.sealed); } + if (tape.*.events.len >= 1000000 || replay_event_valid(raw_event) == 0) { + return replay_tape_fail(tape, ReplayError.invalid_event); + } + let event: ReplayEvent = raw_event; + event.sequence = tape.*.revision + 1; + push[ReplayEvent](&tape.*.events, event); + tape.*.revision = tape.*.revision + 1; + tape.*.content_hash = replay_hash_event(tape.*.content_hash, event); + tape.*.last_error = ReplayError.none; + tape.*.error_index = 0 - 1; + return ReplayError.none; +} + +fn replay_tape_seal(tape: *ReplayTape) ReplayError { + if (tape.*.sealed != 0) { return ReplayError.none; } + tape.*.sealed = 1; + tape.*.last_error = ReplayError.none; + tape.*.error_index = 0 - 1; + return ReplayError.none; +} + +fn replay_tape_verify(tape: ReplayTape) ReplayError { + if (tape.sealed == 0) { return ReplayError.not_sealed; } + if (tape.revision != tape.events.len as i64) { return ReplayError.content_mutated; } + let hash: i64 = 17; + let i: i32 = 0; + while (i < tape.events.len) { + let event: ReplayEvent = tape.events.data[i]; + if (event.sequence != (i as i64) + 1 || replay_event_valid(event) == 0) { + return ReplayError.content_mutated; + } + hash = replay_hash_event(hash, event); + i = i + 1; + } + if (hash != tape.content_hash) { return ReplayError.content_mutated; } + return ReplayError.none; +} + +fn replay_result_error(error: ReplayError, event_index: i32, applied: i64) ReplayResult { + return ReplayResult{ + .error = error, + .error_event = event_index, + .events_applied = applied, + .state_value = 0, + .state_revision = 0, + .clock = 0, + .backend_id = 0, + .backend_generation = 0, + .backend_state = ReplayBackendState.unavailable, + .input_hash = 0, + .semantics_hash = 0, + .layout_hash = 0, + .display_hash = 0, + .cpu_hash = 0, + .output_hash = 0, + }; +} + +fn replay_hit_tree() HitTree { + let tree: HitTree = hit_tree_make(); + let root: HitNode = hit_node(node_key(100), hit_root_key(), rect(0, 0, 8 * unit_scale(), 4 * unit_scale())); + root.z_order = 0; + _ = hit_tree_add(&tree, root); + let button: HitNode = hit_node(node_key(101), node_key(100), rect(0, 0, 4 * unit_scale(), 4 * unit_scale())); + button.z_order = 1; + button.focusable = 1; + _ = hit_tree_add(&tree, button); + return tree; +} + +fn replay_hash_text(hash: i64, text: ArrayList[u8]) i64 { + let result: i64 = display_mix(hash, text.len as i64); + let i: i32 = 0; + while (i < text.len) { + result = display_mix(result, text.data[i] as i64); + i = i + 1; + } + return result; +} + +fn replay_semantics_hash(tree: SemanticsTree) i64 { + let hash: i64 = display_mix(17, tree.revision); + let i: i32 = 0; + while (i < tree.nodes.len) { + let node: SemanticsNode = tree.nodes.data[i]; + hash = display_mix(hash, node.id.value); + hash = display_mix(hash, node.id.generation); + hash = display_mix(hash, node.parent.value); + hash = display_mix(hash, node.parent.generation); + hash = display_mix(hash, node.role as i64); + hash = replay_hash_text(hash, node.name); + hash = replay_hash_text(hash, node.value); + hash = display_mix(hash, node.action_mask); + hash = display_mix(hash, node.focus_order); + hash = display_mix(hash, node.disabled as i64); + hash = display_mix(hash, node.selected as i64); + hash = display_mix(hash, node.hidden as i64); + hash = display_mix(hash, node.live_region as i64); + hash = display_mix(hash, node.has_range as i64); + hash = display_mix(hash, node.range_min); + hash = display_mix(hash, node.range_max); + hash = display_mix(hash, node.range_value); + hash = display_mix(hash, node.range_step); + hash = display_mix(hash, node.text_length); + hash = display_mix(hash, node.selection_start); + hash = display_mix(hash, node.selection_end); + i = i + 1; + } + return hash; +} + +fn replay_input_hash(router: InputRouter) i64 { + let hash: i64 = display_mix(17, router.events.len as i64); + let i: i32 = 0; + while (i < router.events.len) { + let event: RoutedEvent = router.events.data[i]; + hash = display_mix(hash, event.sequence); + hash = display_mix(hash, event.pointer_id); + hash = display_mix(hash, event.phase as i64); + hash = display_mix(hash, event.target.value); + hash = display_mix(hash, event.target.generation); + hash = display_mix(hash, event.local_x); + hash = display_mix(hash, event.local_y); + hash = display_mix(hash, event.captured as i64); + hash = display_mix(hash, event.status as i64); + i = i + 1; + } + hash = display_mix(hash, router.focus.value); + return display_mix(hash, router.focus.generation); +} + +fn replay_layout_hash(layout: FlexResult) i64 { + let hash: i64 = display_mix(display_mix(17, layout.container.width), layout.container.height); + hash = display_mix(hash, layout.overflow); + hash = display_mix(hash, layout.duplicate_ids as i64); + let i: i32 = 0; + while (i < layout.placements.len) { + let placement: FlexPlacement = layout.placements.data[i]; + hash = display_mix(hash, placement.id); + hash = display_mix(hash, placement.frame.x); + hash = display_mix(hash, placement.frame.y); + hash = display_mix(hash, placement.frame.width); + hash = display_mix(hash, placement.frame.height); + hash = display_mix(hash, placement.baseline); + i = i + 1; + } + return hash; +} + +fn replay_build_scene(state_value: i64, backend_generation: i64) ReplayResult { + let result: ReplayResult = replay_result_error(ReplayError.none, 0 - 1, 0); + + let semantics: SemanticsTree = semantics_tree_make(); + let root_spec: SemanticsSpec = semantics_spec(node_key(100), semantic_root_key(), SemanticRole.application, "Replay scene"); + if (semantics_add(&semantics, root_spec) != SemanticsError.none) { + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } + let value_text: []u8 = "even"; + if (state_value % 2 != 0) { value_text = "odd"; } + let button_spec: SemanticsSpec = semantics_spec(node_key(101), node_key(100), SemanticRole.button, "Counter"); + button_spec.value = value_text; + button_spec.action_mask = semantic_actions(SemanticAction.activate); + button_spec.focus_order = 1; + button_spec.selected = ((state_value % 2) != 0) as i32; + if (semantics_add(&semantics, button_spec) != SemanticsError.none) { + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } + result.semantics_hash = replay_semantics_hash(semantics); + + let magnitude: i64 = state_value % 3; + if (magnitude < 0) { magnitude = 0 - magnitude; } + let items: ArrayList[FlexItem] = make[FlexItem](2); + let first: FlexItem = flex_item(101, (2 + magnitude) * unit_scale(), 4 * unit_scale()); + first.shrink = 0; + push[FlexItem](&items, first); + let second: FlexItem = flex_item(102, 2 * unit_scale(), 4 * unit_scale()); + second.shrink = 0; + push[FlexItem](&items, second); + let style: FlexStyle = flex_style(FlexAxis.row, unit_scale(), insets_all(0)); + let layout: FlexResult = flex_layout(items, tight_constraints(8 * unit_scale(), 4 * unit_scale()), style); + free[FlexItem](&items); + if (layout.duplicate_ids != 0 || layout.placements.len != 2) { + flex_free(&layout); + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } + result.layout_hash = replay_layout_hash(layout); + + let list: DisplayList = display_list_make(); + let first_op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(101)); + first_op.bounds = layout.placements.data[0].frame; + let blue: i64 = (backend_generation % 8) * 4096; + first_op.paint = paint_rgba16(8192 + magnitude * 8192, 24576, 32768 + blue, 65535); + if (display_list_push(&list, first_op) != DisplayError.none) { + display_list_free(&list); + flex_free(&layout); + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } + let second_op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(102)); + second_op.bounds = layout.placements.data[1].frame; + second_op.paint = paint_rgba16(49152, 16384 + magnitude * 4096, 12288, 65535); + if (display_list_push(&list, second_op) != DisplayError.none || + display_list_seal(&list) != DisplayError.none) { + display_list_free(&list); + flex_free(&layout); + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } + result.display_hash = list.content_hash; + + let raster: CpuRasterResult = cpu_rasterize(list, 8, 4); + if (raster.error != CpuRasterError.none) { + cpu_raster_free(&raster); + display_list_free(&list); + flex_free(&layout); + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } + result.cpu_hash = cpu_surface_hash(raster.surface); + + cpu_raster_free(&raster); + display_list_free(&list); + flex_free(&layout); + semantics_tree_free(&semantics); + return result; +} + +fn replay_execute(tape: ReplayTape) ReplayResult { + let verified: ReplayError = replay_tape_verify(tape); + if (verified != ReplayError.none) { return replay_result_error(verified, 0 - 1, 0); } + + let state: State[i64] = state_make[i64](1, 0); + let tree: HitTree = replay_hit_tree(); + let router: InputRouter = input_router_make(); + let clock: i64 = 0; + let backend_id: i64 = 0; + let backend_generation: i64 = 0; + let backend_state: ReplayBackendState = ReplayBackendState.unavailable; + let i: i32 = 0; + while (i < tape.events.len) { + let event: ReplayEvent = tape.events.data[i]; + if (event.kind == ReplayEventKind.state_i64) { + if (event.a != state.id || event.b != state.revision) { + input_router_free(&router); hit_tree_free(&tree); + return replay_result_error(ReplayError.stale_state_revision, i, i as i64); + } + _ = state_set[i64](&state, event.c); + } else if (event.kind == ReplayEventKind.pointer) { + let phase: PointerPhase = event.b as PointerPhase; + let routed: RouteResult = input_route_pointer(&router, tree, pointer_event(event.a, phase, event.c, event.d)); + if (routed.status != RouteStatus.accepted) { + input_router_free(&router); hit_tree_free(&tree); + return replay_result_error(ReplayError.input_rejected, i, i as i64); + } + } else if (event.kind == ReplayEventKind.clock) { + if (event.a < clock) { + input_router_free(&router); hit_tree_free(&tree); + return replay_result_error(ReplayError.clock_regression, i, i as i64); + } + clock = event.a; + } else if (event.kind == ReplayEventKind.backend_activate) { + if (backend_state != ReplayBackendState.unavailable) { + input_router_free(&router); hit_tree_free(&tree); + return replay_result_error(ReplayError.backend_transition, i, i as i64); + } + backend_id = event.a; + backend_generation = backend_generation + 1; + backend_state = ReplayBackendState.active; + } else if (event.kind == ReplayEventKind.backend_loss) { + if (backend_state != ReplayBackendState.active || event.a != backend_id) { + input_router_free(&router); hit_tree_free(&tree); + return replay_result_error(ReplayError.backend_transition, i, i as i64); + } + backend_state = ReplayBackendState.lost; + } else if (event.kind == ReplayEventKind.backend_recover) { + if (backend_state != ReplayBackendState.lost || event.a != backend_id) { + input_router_free(&router); hit_tree_free(&tree); + return replay_result_error(ReplayError.backend_transition, i, i as i64); + } + backend_generation = backend_generation + 1; + backend_state = ReplayBackendState.active; + } + i = i + 1; + } + + let scene: ReplayResult = replay_build_scene(state.value, backend_generation); + if (scene.error != ReplayError.none) { + input_router_free(&router); hit_tree_free(&tree); + return scene; + } + let result: ReplayResult = scene; + result.error = ReplayError.none; + result.error_event = 0 - 1; + result.events_applied = tape.events.len as i64; + result.state_value = state.value; + result.state_revision = state.revision; + result.clock = clock; + result.backend_id = backend_id; + result.backend_generation = backend_generation; + result.backend_state = backend_state; + result.input_hash = replay_input_hash(router); + let hash: i64 = display_mix(17, result.state_value); + hash = display_mix(hash, result.state_revision); + hash = display_mix(hash, result.clock); + hash = display_mix(hash, result.backend_id); + hash = display_mix(hash, result.backend_generation); + hash = display_mix(hash, result.backend_state as i64); + hash = display_mix(hash, result.input_hash); + hash = display_mix(hash, result.semantics_hash); + hash = display_mix(hash, result.layout_hash); + hash = display_mix(hash, result.display_hash); + result.output_hash = display_mix(hash, result.cpu_hash); + + input_router_free(&router); + hit_tree_free(&tree); + return result; +} diff --git a/tests/replay_contract.zag b/tests/replay_contract.zag new file mode 100644 index 0000000..48a3938 --- /dev/null +++ b/tests/replay_contract.zag @@ -0,0 +1,175 @@ +@import("../src/replay/replay.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn complete_tape(final_value: i64, pointer_x: i64) ReplayTape { + let tape: ReplayTape = replay_tape_make(); + _ = replay_tape_push(&tape, replay_backend_activate_event(7)); + _ = replay_tape_push(&tape, replay_clock_event(0)); + _ = replay_tape_push(&tape, replay_state_event(1, 0, 1)); + _ = replay_tape_push(&tape, replay_pointer_event(9, PointerPhase.down, pointer_x, unit_scale())); + _ = replay_tape_push(&tape, replay_pointer_event(9, PointerPhase.move, pointer_x + unit_scale(), unit_scale())); + _ = replay_tape_push(&tape, replay_pointer_event(9, PointerPhase.up, pointer_x + unit_scale(), unit_scale())); + _ = replay_tape_push(&tape, replay_clock_event(10)); + _ = replay_tape_push(&tape, replay_state_event(1, 1, final_value)); + _ = replay_tape_push(&tape, replay_backend_loss_event(7)); + _ = replay_tape_push(&tape, replay_backend_recover_event(7)); + _ = replay_tape_push(&tape, replay_clock_event(20)); + _ = replay_tape_seal(&tape); + return tape; +} + +fn results_equal(a: ReplayResult, b: ReplayResult) i32 { + return (a.error == b.error && a.error_event == b.error_event && + a.events_applied == b.events_applied && a.state_value == b.state_value && + a.state_revision == b.state_revision && a.clock == b.clock && + a.backend_id == b.backend_id && a.backend_generation == b.backend_generation && + a.backend_state == b.backend_state && a.input_hash == b.input_hash && + a.semantics_hash == b.semantics_hash && a.layout_hash == b.layout_hash && + a.display_hash == b.display_hash && a.cpu_hash == b.cpu_hash && + a.output_hash == b.output_hash) as i32; +} + +fn deterministic_contract(state: *TestState) void { + let tape: ReplayTape = complete_tape(2, unit_scale()); + let first: ReplayResult = replay_execute(tape); + let second: ReplayResult = replay_execute(tape); + expect(state, replay_tape_verify(tape) == ReplayError.none && tape.events.len == 11, + "sealed tape preserves one canonical ordered event stream"); + expect(state, first.error == ReplayError.none && first.events_applied == 11, + "complete tape applies every event without hidden skips"); + expect(state, first.state_value == 2 && first.state_revision == 2 && first.clock == 20, + "state revisions and monotonic clock replay exactly"); + expect(state, first.backend_id == 7 && first.backend_generation == 2 && + first.backend_state == ReplayBackendState.active, + "backend activation loss and recovery remain observable in final truth"); + expect(state, first.input_hash != 0 && first.semantics_hash != 0 && first.layout_hash != 0 && + first.display_hash != 0 && first.cpu_hash != 0 && first.output_hash != 0, + "replay regenerates input semantics layout display and CPU identities"); + expect(state, results_equal(first, second) == 1, + "two executions of the same sealed tape are bit-identical"); + replay_tape_free(&tape); +} + +fn mutation_contract(state: *TestState) void { + let tape: ReplayTape = complete_tape(2, unit_scale()); + tape.events.data[2].c = 99; + let mutated: ReplayResult = replay_execute(tape); + expect(state, replay_tape_verify(tape) == ReplayError.content_mutated && + mutated.error == ReplayError.content_mutated && mutated.events_applied == 0, + "sealed tape mutation fails before any event is applied"); + replay_tape_free(&tape); + + let open: ReplayTape = replay_tape_make(); + _ = replay_tape_push(&open, replay_clock_event(1)); + expect(state, replay_execute(open).error == ReplayError.not_sealed, + "unsealed tapes cannot masquerade as replay evidence"); + _ = replay_tape_seal(&open); + expect(state, replay_tape_push(&open, replay_clock_event(2)) == ReplayError.sealed && open.events.len == 1, + "sealed tapes reject appended events without mutation"); + replay_tape_free(&open); + + let invalid: ReplayTape = replay_tape_make(); + expect(state, replay_tape_push(&invalid, replay_clock_event(0 - 1)) == ReplayError.invalid_event && + invalid.events.len == 0 && invalid.revision == 0, + "invalid events fail before tape revision or hash changes"); + let noncanonical: ReplayEvent = replay_clock_event(1); + noncanonical.b = 1; + expect(state, replay_tape_push(&invalid, noncanonical) == ReplayError.invalid_event && invalid.events.len == 0, + "unused event fields must stay zero for one canonical tape representation"); + replay_tape_free(&invalid); +} + +fn state_and_clock_failure_contract(state: *TestState) void { + let stale: ReplayTape = replay_tape_make(); + _ = replay_tape_push(&stale, replay_clock_event(1)); + _ = replay_tape_push(&stale, replay_state_event(1, 4, 3)); + _ = replay_tape_seal(&stale); + let stale_result: ReplayResult = replay_execute(stale); + expect(state, stale_result.error == ReplayError.stale_state_revision && + stale_result.error_event == 1 && stale_result.events_applied == 1, + "stale state revisions fail at the exact ordered event"); + replay_tape_free(&stale); + + let regressed: ReplayTape = replay_tape_make(); + _ = replay_tape_push(®ressed, replay_clock_event(10)); + _ = replay_tape_push(®ressed, replay_clock_event(9)); + _ = replay_tape_seal(®ressed); + let clock_result: ReplayResult = replay_execute(regressed); + expect(state, clock_result.error == ReplayError.clock_regression && + clock_result.error_event == 1 && clock_result.events_applied == 1, + "clock regression fails at the exact ordered event"); + replay_tape_free(®ressed); +} + +fn backend_failure_contract(state: *TestState) void { + let loss: ReplayTape = replay_tape_make(); + _ = replay_tape_push(&loss, replay_backend_loss_event(7)); + _ = replay_tape_seal(&loss); + expect(state, replay_execute(loss).error == ReplayError.backend_transition, + "device loss without an active backend fails closed"); + replay_tape_free(&loss); + + let recover: ReplayTape = replay_tape_make(); + _ = replay_tape_push(&recover, replay_backend_activate_event(7)); + _ = replay_tape_push(&recover, replay_backend_recover_event(7)); + _ = replay_tape_seal(&recover); + let recover_result: ReplayResult = replay_execute(recover); + expect(state, recover_result.error == ReplayError.backend_transition && recover_result.error_event == 1, + "recovery without a recorded loss fails closed"); + replay_tape_free(&recover); + + let wrong: ReplayTape = replay_tape_make(); + _ = replay_tape_push(&wrong, replay_backend_activate_event(7)); + _ = replay_tape_push(&wrong, replay_backend_loss_event(8)); + _ = replay_tape_seal(&wrong); + expect(state, replay_execute(wrong).error == ReplayError.backend_transition, + "backend identity cannot silently change during loss"); + replay_tape_free(&wrong); +} + +fn differential_contract(state: *TestState) void { + let baseline: ReplayTape = complete_tape(2, unit_scale()); + let changed_state: ReplayTape = complete_tape(3, unit_scale()); + let changed_pointer: ReplayTape = complete_tape(2, 6 * unit_scale()); + let a: ReplayResult = replay_execute(baseline); + let b: ReplayResult = replay_execute(changed_state); + let c: ReplayResult = replay_execute(changed_pointer); + expect(state, a.output_hash != b.output_hash && a.semantics_hash != b.semantics_hash && + a.layout_hash != b.layout_hash && a.display_hash != b.display_hash && a.cpu_hash != b.cpu_hash, + "state changes propagate through semantics Flex display and CPU output"); + expect(state, a.input_hash != c.input_hash && a.semantics_hash == c.semantics_hash && + a.layout_hash == c.layout_hash && a.display_hash == c.display_hash && a.cpu_hash == c.cpu_hash && + a.output_hash != c.output_hash, + "pointer differences change input identity without contaminating scene generation"); + replay_tape_free(&baseline); + replay_tape_free(&changed_state); + replay_tape_free(&changed_pointer); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + deterministic_contract(&state); + mutation_contract(&state); + state_and_clock_failure_contract(&state); + backend_failure_contract(&state); + differential_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Replay contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); + _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index e2222f4..8623cef 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -30,5 +30,7 @@ cd "$root" "$tmp/cpu-raster-contract" "$znc" tests/input_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/input-contract" "$tmp/input-contract" +"$znc" tests/replay_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/replay-contract" +"$tmp/replay-contract" -printf 'headless test: PASS (state, reconciliation, constraints, Flex, semantics, Talkback, display lists, CPU raster, and input)\n' +printf 'headless test: PASS (state, reconciliation, constraints, Flex, semantics, Talkback, display lists, CPU raster, input, and replay)\n' From cac9b3d84af1bf91aa1ef6cdd2723020667e52b3 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 06:50:07 -0700 Subject: [PATCH 015/103] Implement deterministic motion foundation --- CHANGELOG.md | 6 +- README.md | 28 +- docs/architecture/README.md | 16 +- docs/architecture/motion.md | 79 +++++ src/motion/motion.zag | 654 ++++++++++++++++++++++++++++++++++++ src/replay/replay.zag | 56 ++- tests/motion_contract.zag | 227 +++++++++++++ tests/replay_contract.zag | 22 +- tools/test-headless.sh | 4 +- 9 files changed, 1063 insertions(+), 29 deletions(-) create mode 100644 docs/architecture/motion.md create mode 100644 src/motion/motion.zag create mode 100644 tests/motion_contract.zag diff --git a/CHANGELOG.md b/CHANGELOG.md index f135e64..083191f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,11 @@ include a generated capability record and exact Zag compiler revision. z-order, capture, focus, stale-target recovery, and ordered evidence. - A sealed deterministic replay tape and conformance executor covering exact state revisions, pointer input, monotonic time, backend activation, loss, - recovery, and regenerated semantics, Flex, display-list, and CPU identities. + recovery, and regenerated motion, semantics, Flex, display-list, and CPU identities. +- A refresh-aware deterministic motion scheduler with fixed-step integer + springs, owned keyframes, explicit frame reasons, interruption, reversal, + resize continuity, gesture velocity handoff, and semantic reduced-motion + substitutions that perform no continuous work after settling. ## 0.1.0-experimental.0 (unreleased) diff --git a/README.md b/README.md index 886153b..94c39aa 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ modern materials and asset fidelity, and a complete PrismStudio UI replacement. This repository is at **0.1.0-experimental.0**. It currently contains the accepted product contract, executable Milestone 0 checks, and the first deterministic state, keyed reconciliation, geometry, Flex, semantics, Talkback, -display-list, CPU-oracle, input, and replay slices. It does not yet contain a -usable renderer, window shell, component library, or supported platform +display-list, CPU-oracle, input, replay, and motion slices. It does not yet +contain a usable renderer, window shell, component library, or supported platform backend. Nothing in this repository is a Zagkit 1.0 release. ## What Zagkit owns @@ -41,7 +41,7 @@ is normative in [DEPENDENCIES.md](DEPENDENCIES.md). | Product and architecture contract | accepted | [RFC index](docs/rfcs/README.md) | | Compiler dependency | pinned, prerequisites incomplete | [toolchain lock](contracts/toolchain.json) | | Platform shells | unavailable | [support matrix](SUPPORT.md) | -| Headless core | experimental state, reconciliation, geometry, Flex, semantics, Talkback, display lists, CPU raster, input, and replay | [headless test](tools/test-headless.sh) | +| Headless core | experimental state, reconciliation, geometry, Flex, semantics, Talkback, display lists, CPU raster, input, replay, and motion | [headless test](tools/test-headless.sh) | | Components and visual language | inventory only, visual review pending | [component inventory](contracts/components.json) | | Flex and Zagkit Talkback | Flex foundation and in-process ID-first Talkback dispatch executing; native transport remains unavailable | [Talkback contract](docs/automation/talkback.md) | | Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | @@ -61,8 +61,8 @@ action-producing `Binding`, inherited integer environment values, exact per-node state-read records, fail-visible keyed reconciliation, deterministic fixed-point geometry, and single-line Flex. These APIs are experimental. Typed environment values, reconciliation cancellation, child ownership, replay -serialization, intrinsic measurement, wrapping, grid, overlay, and breakpoints remain open; -the corresponding Milestone 2 checklist items are not complete. +serialization, intrinsic measurement, wrapping, grid, overlay, and breakpoints +remain open; the corresponding Milestone 2 checklist items are not complete. The semantics slice owns copied names and values, stable keys, explicit action capabilities, deterministic focus order, live-region state, ranges, selection, @@ -104,12 +104,24 @@ gestures, and platform input adaptation remain open. The first replay slice owns a sealed ordered tape for exact state revisions, pointer phases, monotonic time, backend activation, device loss, and recovery. -Executing the same tape regenerates identical semantic, Flex, display-list, and -CPU identities; raw mutation, stale revisions, clock regression, and invalid -backend transitions fail before they can become evidence. The current scene is +Executing the same tape regenerates identical motion, semantic, Flex, +display-list, and CPU identities; raw mutation, stale revisions, clock +regression, and invalid backend transitions fail before they can become evidence. The current scene is an executable conformance reference, not yet a general application callback boundary or a versioned replay-file format, so `G2-REPLAY` remains open. +The experimental motion kernel uses supplied monotonic microseconds and the +same 26.6 logical units as layout. Its refresh-aware scheduler records why +frames exist, stops requesting frames when every track settles, and refuses +clock advances that would skip live motion. Fixed-step integer springs preserve +position and velocity through interruption, reversal, resize, and gesture +handoff. Owned keyframe timelines expose exact segment velocity. Reduced motion +uses explicit snap or bounded opacity-fade substitutions rather than a global +duration multiplier. Layout/shared-transition orchestration, easing curves, +platform vsync adapters, retained event-log bounds, and performance evidence +remain open, so `G3-MOTION` and `G3-REDUCED-MOTION` are not complete. See the +[motion contract](docs/architecture/motion.md). + Run the deterministic headless foundation test with: ```sh diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 7180ec9..adca707 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -26,6 +26,10 @@ Normative decisions: - [platform seams and backend truth](../rfcs/0004-platform-seams-and-backend-truth.md) - [quality and release](../rfcs/0005-quality-and-release-contract.md) +Detailed experimental contracts: + +- [motion scheduler and tracks](motion.md) + The first experimental compiled slice now fixes the initial Zag shapes for `NodeKey`, `State`, `Binding`, `Action`, `Environment`, `ViewContext`, `ViewSpec`, and `RenderNode`. State reads record their reader and revision; @@ -70,7 +74,15 @@ The first deterministic replay executor consumes an immutable ordered tape of state revisions, pointer events, monotonic clock samples, backend activation, loss, and recovery. It rejects stale or impossible transitions at their exact event index, then rebuilds a conformance scene through the real semantics, -Flex, display-list, and CPU-oracle paths. A repeated tape must produce identical -subsystem and aggregate hashes. A general application callback contract, +motion, Flex, display-list, and CPU-oracle paths. A repeated tape must produce +identical subsystem and aggregate hashes. A general application callback contract, versioned tape codec, resource capture, and platform lifecycle integration are still open. + +The motion scheduler consumes an authoritative monotonic clock and advertises +the current refresh interval without deriving motion from callback count. +Integer fixed-step springs and owned keyframe timelines retain stable track +identity, exact position and velocity, explicit frame reasons, interruption, +reversal, resize retargeting, gesture handoff, and reduced-motion substitution. +Settled tracks request no further frame. The full contract and current limits +are documented in [motion.md](motion.md). diff --git a/docs/architecture/motion.md b/docs/architecture/motion.md new file mode 100644 index 0000000..3833538 --- /dev/null +++ b/docs/architecture/motion.md @@ -0,0 +1,79 @@ +# Experimental motion contract + +Zagkit motion is driven by an explicit monotonic clock. Platform callbacks do +not define animation progress, so the same clock and event sequence must +produce the same track state and evidence on every backend. + +## Units and ownership + +- Values use signed 26.6 logical units; 64 units equal one density-independent + logical pixel. Velocity uses those fixed-point units per second. +- Time uses monotonic microseconds supplied by the host or deterministic replay. +- A `MotionScheduler` owns every `MotionTrack`, copied keyframe, and retained + `MotionEvent`. `motion_scheduler_free` releases that complete ownership tree. +- Stable positive track IDs reject duplicates before allocation. + +## Scheduling truth + +The scheduler records its active refresh rate and integer frame interval for +24 through 480 Hz. Refresh changes emit `refresh_rate_change`. A running track +causes `motion_needs_frame` and `MotionTickResult.request_next_frame` to return +true. When every track settles, later ticks perform no work, append no event, +and request no frame. + +Clock regression and individual clock jumps over one second fail before time or +track mutation. Once motion is running, callers must advance time through +`motion_tick`; `motion_set_clock` cannot silently skip evaluation. + +Every evaluated active frame emits an ordered `tick` reason. Starts, +interruptions, reversals, resize retargeting, gesture handoff, reduced-motion +substitution, and refresh changes retain their own reason and exact track state. + +## Springs + +Springs use deterministic integer semi-implicit integration in fixed one +millisecond steps. Sub-unit velocity and position remainders are retained so a +spring cannot stall because of integer truncation. Grouping identical elapsed +time into 60 Hz, 120 Hz, or irregular callbacks produces identical position +and velocity. + +Retargeting preserves visible position and incoming velocity. Interruption, +reversal, and resize are semantic reasons for the same continuity operation. +Gesture handoff replaces position and velocity with the recognizer's exact +sample and continues toward the supplied target. When configured position and +velocity tolerances are both reached, the track snaps exactly to its target, +zeros velocity, and becomes idle. + +## Keyframes + +A keyframe timeline starts at zero microseconds, contains strictly increasing +times, and is copied into scheduler ownership. Linear interpolation reports +exact constant velocity within each segment and settles exactly on the final +value. Schedulers and timelines are each bounded to 4,096 tracks or samples, +and a timeline is bounded to 60 seconds; malformed order, +unsafe values, and unsafe segment velocity fail before ownership transfer. + +Additional easing curves, keyframe interruption into a replacement timeline, +and spline velocity matching remain open. + +## Reduced motion + +Reduced motion is an explicit semantic substitution: + +- spatial springs use `snap_to_end`; +- components may supply a separate `opacity_fade` keyframe timeline; +- an opacity substitute is limited to values from zero through one logical + unit and a maximum duration of 250 milliseconds. + +Zagkit does not multiply every duration by a global factor. A spatial spring +cannot be mislabeled as an opacity fade. Component-level substitutions and +their semantic equivalence still require conformance coverage before +`G3-REDUCED-MOTION` can be completed. + +## Current boundary + +This slice proves the deterministic headless kernel. It does not yet provide +layout or shared-transition orchestration, gesture recognition, platform vsync +adapters, bounded long-running event retention, easing families, visual +goldens, p99 frame evidence, 120 Hz device certification, or ten-minute stall +traces. Those remain required by the master goal and release gates. diff --git a/src/motion/motion.zag b/src/motion/motion.zag new file mode 100644 index 0000000..69947d0 --- /dev/null +++ b/src/motion/motion.zag @@ -0,0 +1,654 @@ +@import("std:list") +@import("../render/display_list.zag") + +// Motion values use the same signed 26.6 logical-unit representation as +// layout. Time is supplied explicitly in monotonic microseconds. + +enum MotionKind { spring, keyframes } +enum MotionStatus { running, settled } +enum MotionPreference { full, reduced } +enum MotionReducedSubstitution { snap_to_end, opacity_fade } + +enum MotionFrameReason { + start, + tick, + interrupt, + reverse, + gesture_handoff, + resize, + reduced_motion, + refresh_rate_change, +} + +enum MotionError { + none, + duplicate_id, + missing_track, + invalid_spec, + invalid_keyframes, + clock_regression, + clock_advance_requires_tick, + clock_step_too_large, +} + +struct SpringSpec { + stiffness: i64, + damping: i64, + position_epsilon: i64, + velocity_epsilon: i64, +} + +struct MotionKeyframe { + time_us: i64, + value: i64, +} + +struct MotionTrack { + id: i64, + kind: MotionKind, + status: MotionStatus, + value: i64, + velocity: i64, + target: i64, + spring: SpringSpec, + keyframes: ArrayList[MotionKeyframe], + elapsed_us: i64, + remainder_us: i64, + velocity_remainder: i64, + position_remainder: i64, +} + +struct MotionEvent { + sequence: i64, + reason: MotionFrameReason, + track_id: i64, + time_us: i64, + value: i64, + velocity: i64, + target: i64, +} + +struct MotionTickResult { + error: MotionError, + did_work: i32, + updated_tracks: i32, + running_tracks: i32, + request_next_frame: i32, + frame_interval_us: i64, +} + +struct MotionSample { + found: i32, + id: i64, + kind: MotionKind, + status: MotionStatus, + value: i64, + velocity: i64, + target: i64, + elapsed_us: i64, +} + +struct MotionScheduler { + tracks: ArrayList[MotionTrack], + events: ArrayList[MotionEvent], + now_us: i64, + has_clock: i32, + refresh_hz: i64, + frame_interval_us: i64, + next_sequence: i64, + last_error: MotionError, + error_track: i64, +} + +fn motion_abs(value: i64) i64 { + if (value < 0) { return 0 - value; } + return value; +} + +fn motion_value_valid(value: i64) i32 { + return (value >= -1000000000 && value <= 1000000000) as i32; +} + +fn spring_spec(stiffness: i64, damping: i64) SpringSpec { + return SpringSpec{ + .stiffness = stiffness, + .damping = damping, + .position_epsilon = 1, + .velocity_epsilon = unit_scale(), + }; +} + +fn motion_spring_valid(spec: SpringSpec) i32 { + return (spec.stiffness > 0 && spec.stiffness <= 4096 && + spec.damping >= 0 && spec.damping <= 1024 && + spec.position_epsilon >= 0 && spec.position_epsilon <= unit_scale() * 16 && + spec.velocity_epsilon >= 0 && spec.velocity_epsilon <= unit_scale() * 1024) as i32; +} + +fn motion_scheduler_make(refresh_hz: i64) MotionScheduler { + let actual_hz: i64 = refresh_hz; + if (actual_hz < 24 || actual_hz > 480) { actual_hz = 60; } + return MotionScheduler{ + .tracks = make[MotionTrack](16), + .events = make[MotionEvent](64), + .now_us = 0, + .has_clock = 0, + .refresh_hz = actual_hz, + .frame_interval_us = 1000000 / actual_hz, + .next_sequence = 1, + .last_error = MotionError.none, + .error_track = 0, + }; +} + +fn motion_scheduler_free(scheduler: *MotionScheduler) void { + let i: i32 = 0; + while (i < scheduler.*.tracks.len) { + free[MotionKeyframe](&scheduler.*.tracks.data[i].keyframes); + i = i + 1; + } + free[MotionTrack](&scheduler.*.tracks); + free[MotionEvent](&scheduler.*.events); +} + +fn motion_find_index(scheduler: MotionScheduler, id: i64) i32 { + let i: i32 = scheduler.tracks.len - 1; + while (i >= 0) { + if (scheduler.tracks.data[i].id == id) { return i; } + i = i - 1; + } + return 0 - 1; +} + +fn motion_fail(scheduler: *MotionScheduler, error: MotionError, track_id: i64) MotionError { + scheduler.*.last_error = error; + scheduler.*.error_track = track_id; + return error; +} + +fn motion_clear_error(scheduler: *MotionScheduler) void { + scheduler.*.last_error = MotionError.none; + scheduler.*.error_track = 0; +} + +fn motion_record(scheduler: *MotionScheduler, reason: MotionFrameReason, track_id: i64) void { + let value: i64 = 0; + let velocity: i64 = 0; + let target: i64 = 0; + let index: i32 = motion_find_index(scheduler.*, track_id); + if (index >= 0) { + value = scheduler.*.tracks.data[index].value; + velocity = scheduler.*.tracks.data[index].velocity; + target = scheduler.*.tracks.data[index].target; + } + push[MotionEvent](&scheduler.*.events, MotionEvent{ + .sequence = scheduler.*.next_sequence, + .reason = reason, + .track_id = track_id, + .time_us = scheduler.*.now_us, + .value = value, + .velocity = velocity, + .target = target, + }); + scheduler.*.next_sequence = scheduler.*.next_sequence + 1; +} + +fn motion_set_clock(scheduler: *MotionScheduler, time_us: i64) MotionError { + if (time_us < 0) { return motion_fail(scheduler, MotionError.invalid_spec, 0); } + if (scheduler.*.has_clock != 0 && time_us < scheduler.*.now_us) { + return motion_fail(scheduler, MotionError.clock_regression, 0); + } + if (scheduler.*.has_clock != 0 && time_us > scheduler.*.now_us && + motion_running_count(scheduler.*) > 0) { + return motion_fail(scheduler, MotionError.clock_advance_requires_tick, 0); + } + scheduler.*.now_us = time_us; + scheduler.*.has_clock = 1; + motion_clear_error(scheduler); + return MotionError.none; +} + +fn motion_running_count(scheduler: MotionScheduler) i32 { + let count: i32 = 0; + let i: i32 = 0; + while (i < scheduler.tracks.len) { + if (scheduler.tracks.data[i].status == MotionStatus.running) { count = count + 1; } + i = i + 1; + } + return count; +} + +fn motion_needs_frame(scheduler: MotionScheduler) i32 { + return (motion_running_count(scheduler) > 0) as i32; +} + +fn motion_empty_keyframes() ArrayList[MotionKeyframe] { + return make[MotionKeyframe](1); +} + +fn motion_start_spring( + scheduler: *MotionScheduler, + id: i64, + value: i64, + target: i64, + spec: SpringSpec, + preference: MotionPreference, + substitution: MotionReducedSubstitution, +) MotionError { + if (scheduler.*.has_clock == 0 || id <= 0 || scheduler.*.tracks.len >= 4096 || + motion_find_index(scheduler.*, id) >= 0) { + if (motion_find_index(scheduler.*, id) >= 0) { + return motion_fail(scheduler, MotionError.duplicate_id, id); + } + return motion_fail(scheduler, MotionError.invalid_spec, id); + } + if (motion_value_valid(value) == 0 || motion_value_valid(target) == 0 || motion_spring_valid(spec) == 0) { + return motion_fail(scheduler, MotionError.invalid_spec, id); + } + if (preference == MotionPreference.reduced && substitution != MotionReducedSubstitution.snap_to_end) { + return motion_fail(scheduler, MotionError.invalid_spec, id); + } + let track: MotionTrack = MotionTrack{ + .id = id, + .kind = MotionKind.spring, + .status = MotionStatus.running, + .value = value, + .velocity = 0, + .target = target, + .spring = spec, + .keyframes = motion_empty_keyframes(), + .elapsed_us = 0, + .remainder_us = 0, + .velocity_remainder = 0, + .position_remainder = 0, + }; + if (value == target) { track.status = MotionStatus.settled; } + if (preference == MotionPreference.reduced && substitution == MotionReducedSubstitution.snap_to_end) { + track.value = target; + track.status = MotionStatus.settled; + } + push[MotionTrack](&scheduler.*.tracks, track); + if (preference == MotionPreference.reduced) { + motion_record(scheduler, MotionFrameReason.reduced_motion, id); + } else { + motion_record(scheduler, MotionFrameReason.start, id); + } + motion_clear_error(scheduler); + return MotionError.none; +} + +fn motion_keyframes_valid(keyframes: ArrayList[MotionKeyframe]) i32 { + if (keyframes.len < 2 || keyframes.len > 4096 || keyframes.data[0].time_us != 0) { return 0; } + let i: i32 = 0; + while (i < keyframes.len) { + if (motion_value_valid(keyframes.data[i].value) == 0 || keyframes.data[i].time_us < 0 || + keyframes.data[i].time_us > 60000000) { return 0; } + if (i > 0 && keyframes.data[i].time_us <= keyframes.data[i - 1].time_us) { return 0; } + if (i > 0) { + let duration: i64 = keyframes.data[i].time_us - keyframes.data[i - 1].time_us; + let velocity: i64 = ((keyframes.data[i].value - keyframes.data[i - 1].value) * 1000000) / duration; + if (motion_value_valid(velocity) == 0) { return 0; } + } + i = i + 1; + } + return 1; +} + +fn motion_opacity_keyframes_valid(keyframes: ArrayList[MotionKeyframe]) i32 { + if (motion_keyframes_valid(keyframes) == 0 || keyframes.data[keyframes.len - 1].time_us > 250000) { + return 0; + } + let i: i32 = 0; + while (i < keyframes.len) { + if (keyframes.data[i].value < 0 || keyframes.data[i].value > unit_scale()) { return 0; } + i = i + 1; + } + return 1; +} + +fn motion_copy_keyframes(keyframes: ArrayList[MotionKeyframe]) ArrayList[MotionKeyframe] { + let owned: ArrayList[MotionKeyframe] = make[MotionKeyframe](keyframes.len); + let i: i32 = 0; + while (i < keyframes.len) { + push[MotionKeyframe](&owned, keyframes.data[i]); + i = i + 1; + } + return owned; +} + +fn motion_start_keyframes( + scheduler: *MotionScheduler, + id: i64, + keyframes: ArrayList[MotionKeyframe], + preference: MotionPreference, + substitution: MotionReducedSubstitution, +) MotionError { + if (scheduler.*.has_clock == 0 || id <= 0 || scheduler.*.tracks.len >= 4096 || + motion_find_index(scheduler.*, id) >= 0) { + if (motion_find_index(scheduler.*, id) >= 0) { + return motion_fail(scheduler, MotionError.duplicate_id, id); + } + return motion_fail(scheduler, MotionError.invalid_spec, id); + } + if (motion_keyframes_valid(keyframes) == 0) { + return motion_fail(scheduler, MotionError.invalid_keyframes, id); + } + if (preference == MotionPreference.reduced && substitution == MotionReducedSubstitution.opacity_fade && + motion_opacity_keyframes_valid(keyframes) == 0) { + return motion_fail(scheduler, MotionError.invalid_keyframes, id); + } + let owned: ArrayList[MotionKeyframe] = motion_copy_keyframes(keyframes); + let last: MotionKeyframe = owned.data[owned.len - 1]; + let track: MotionTrack = MotionTrack{ + .id = id, + .kind = MotionKind.keyframes, + .status = MotionStatus.running, + .value = owned.data[0].value, + .velocity = 0, + .target = last.value, + .spring = spring_spec(1, 0), + .keyframes = owned, + .elapsed_us = 0, + .remainder_us = 0, + .velocity_remainder = 0, + .position_remainder = 0, + }; + if (preference == MotionPreference.reduced && substitution == MotionReducedSubstitution.snap_to_end) { + track.value = track.target; + track.status = MotionStatus.settled; + } + if (preference == MotionPreference.reduced && substitution == MotionReducedSubstitution.opacity_fade) { + // The caller supplied an explicit opacity timeline as its semantic + // substitute. It is not derived by multiplying the original duration. + track.status = MotionStatus.running; + } + push[MotionTrack](&scheduler.*.tracks, track); + if (preference == MotionPreference.reduced) { + motion_record(scheduler, MotionFrameReason.reduced_motion, id); + } else { + motion_record(scheduler, MotionFrameReason.start, id); + } + motion_clear_error(scheduler); + return MotionError.none; +} + +fn motion_set_refresh_rate(scheduler: *MotionScheduler, refresh_hz: i64) MotionError { + if (refresh_hz < 24 || refresh_hz > 480) { + return motion_fail(scheduler, MotionError.invalid_spec, 0); + } + if (scheduler.*.refresh_hz != refresh_hz) { + scheduler.*.refresh_hz = refresh_hz; + scheduler.*.frame_interval_us = 1000000 / refresh_hz; + motion_record(scheduler, MotionFrameReason.refresh_rate_change, 0); + } + motion_clear_error(scheduler); + return MotionError.none; +} + +fn motion_spring_step(track: *MotionTrack) void { + let error: i64 = track.*.target - track.*.value; + let acceleration: i64 = error * track.*.spring.stiffness - track.*.velocity * track.*.spring.damping; + let velocity_numerator: i64 = acceleration * 1000 + track.*.velocity_remainder; + let velocity_delta: i64 = velocity_numerator / 1000000; + track.*.velocity_remainder = velocity_numerator - velocity_delta * 1000000; + track.*.velocity = track.*.velocity + velocity_delta; + if (track.*.velocity < -1000000000) { + track.*.velocity = -1000000000; + track.*.velocity_remainder = 0; + } + if (track.*.velocity > 1000000000) { + track.*.velocity = 1000000000; + track.*.velocity_remainder = 0; + } + let position_numerator: i64 = track.*.velocity * 1000 + track.*.position_remainder; + let position_delta: i64 = position_numerator / 1000000; + track.*.position_remainder = position_numerator - position_delta * 1000000; + track.*.value = track.*.value + position_delta; + if (track.*.value < -1000000000) { + track.*.value = -1000000000; + track.*.position_remainder = 0; + } + if (track.*.value > 1000000000) { + track.*.value = 1000000000; + track.*.position_remainder = 0; + } + if (motion_abs(track.*.target - track.*.value) <= track.*.spring.position_epsilon && + motion_abs(track.*.velocity) <= track.*.spring.velocity_epsilon) { + track.*.value = track.*.target; + track.*.velocity = 0; + track.*.status = MotionStatus.settled; + track.*.remainder_us = 0; + track.*.velocity_remainder = 0; + track.*.position_remainder = 0; + } +} + +fn motion_advance_spring(track: *MotionTrack, delta_us: i64) void { + let remaining: i64 = delta_us + track.*.remainder_us; + while (remaining >= 1000 && track.*.status == MotionStatus.running) { + motion_spring_step(track); + remaining = remaining - 1000; + } + if (track.*.status == MotionStatus.running) { track.*.remainder_us = remaining; } +} + +fn motion_advance_keyframes(track: *MotionTrack, delta_us: i64) void { + track.*.elapsed_us = track.*.elapsed_us + delta_us; + let last: MotionKeyframe = track.*.keyframes.data[track.*.keyframes.len - 1]; + if (track.*.elapsed_us >= last.time_us) { + track.*.elapsed_us = last.time_us; + track.*.value = last.value; + track.*.velocity = 0; + track.*.status = MotionStatus.settled; + return; + } + let i: i32 = 1; + while (i < track.*.keyframes.len && track.*.keyframes.data[i].time_us < track.*.elapsed_us) { + i = i + 1; + } + let before: MotionKeyframe = track.*.keyframes.data[i - 1]; + let after: MotionKeyframe = track.*.keyframes.data[i]; + let duration: i64 = after.time_us - before.time_us; + let elapsed: i64 = track.*.elapsed_us - before.time_us; + let difference: i64 = after.value - before.value; + track.*.value = before.value + (difference * elapsed) / duration; + track.*.velocity = (difference * 1000000) / duration; +} + +fn motion_tick(scheduler: *MotionScheduler, time_us: i64) MotionTickResult { + let failed: MotionTickResult = MotionTickResult{ + .error = MotionError.none, + .did_work = 0, + .updated_tracks = 0, + .running_tracks = motion_running_count(scheduler.*), + .request_next_frame = 0, + .frame_interval_us = scheduler.*.frame_interval_us, + }; + if (scheduler.*.has_clock == 0) { + let error: MotionError = motion_set_clock(scheduler, time_us); + failed.error = error; + failed.running_tracks = motion_running_count(scheduler.*); + failed.request_next_frame = (failed.running_tracks > 0) as i32; + return failed; + } + if (time_us < scheduler.*.now_us) { + failed.error = motion_fail(scheduler, MotionError.clock_regression, 0); + return failed; + } + let delta_us: i64 = time_us - scheduler.*.now_us; + if (delta_us > 1000000) { + failed.error = motion_fail(scheduler, MotionError.clock_step_too_large, 0); + return failed; + } + if (delta_us == 0 || failed.running_tracks == 0) { + scheduler.*.now_us = time_us; + failed.request_next_frame = (failed.running_tracks > 0) as i32; + motion_clear_error(scheduler); + return failed; + } + + scheduler.*.now_us = time_us; + let updated: i32 = 0; + let i: i32 = 0; + while (i < scheduler.*.tracks.len) { + if (scheduler.*.tracks.data[i].status == MotionStatus.running) { + if (scheduler.*.tracks.data[i].kind == MotionKind.spring) { + motion_advance_spring(&scheduler.*.tracks.data[i], delta_us); + } else { + motion_advance_keyframes(&scheduler.*.tracks.data[i], delta_us); + } + updated = updated + 1; + } + i = i + 1; + } + motion_record(scheduler, MotionFrameReason.tick, 0); + let running: i32 = motion_running_count(scheduler.*); + motion_clear_error(scheduler); + return MotionTickResult{ + .error = MotionError.none, + .did_work = 1, + .updated_tracks = updated, + .running_tracks = running, + .request_next_frame = (running > 0) as i32, + .frame_interval_us = scheduler.*.frame_interval_us, + }; +} + +fn motion_retarget( + scheduler: *MotionScheduler, + id: i64, + target: i64, + reason: MotionFrameReason, + preference: MotionPreference, + substitution: MotionReducedSubstitution, +) MotionError { + let index: i32 = motion_find_index(scheduler.*, id); + if (index < 0) { return motion_fail(scheduler, MotionError.missing_track, id); } + if (scheduler.*.tracks.data[index].kind != MotionKind.spring || motion_value_valid(target) == 0 || + (reason != MotionFrameReason.interrupt && reason != MotionFrameReason.reverse && reason != MotionFrameReason.resize)) { + return motion_fail(scheduler, MotionError.invalid_spec, id); + } + if (preference == MotionPreference.reduced && substitution != MotionReducedSubstitution.snap_to_end) { + return motion_fail(scheduler, MotionError.invalid_spec, id); + } + scheduler.*.tracks.data[index].target = target; + scheduler.*.tracks.data[index].status = MotionStatus.running; + scheduler.*.tracks.data[index].remainder_us = 0; + scheduler.*.tracks.data[index].velocity_remainder = 0; + if (preference == MotionPreference.reduced && substitution == MotionReducedSubstitution.snap_to_end) { + scheduler.*.tracks.data[index].value = target; + scheduler.*.tracks.data[index].velocity = 0; + scheduler.*.tracks.data[index].status = MotionStatus.settled; + scheduler.*.tracks.data[index].remainder_us = 0; + scheduler.*.tracks.data[index].velocity_remainder = 0; + scheduler.*.tracks.data[index].position_remainder = 0; + motion_record(scheduler, MotionFrameReason.reduced_motion, id); + } else { + motion_record(scheduler, reason, id); + } + motion_clear_error(scheduler); + return MotionError.none; +} + +fn motion_gesture_handoff( + scheduler: *MotionScheduler, + id: i64, + value: i64, + velocity: i64, + target: i64, +) MotionError { + let index: i32 = motion_find_index(scheduler.*, id); + if (index < 0) { return motion_fail(scheduler, MotionError.missing_track, id); } + if (scheduler.*.tracks.data[index].kind != MotionKind.spring || motion_value_valid(value) == 0 || + motion_value_valid(velocity) == 0 || motion_value_valid(target) == 0) { + return motion_fail(scheduler, MotionError.invalid_spec, id); + } + scheduler.*.tracks.data[index].value = value; + scheduler.*.tracks.data[index].velocity = velocity; + scheduler.*.tracks.data[index].target = target; + scheduler.*.tracks.data[index].status = MotionStatus.running; + scheduler.*.tracks.data[index].remainder_us = 0; + scheduler.*.tracks.data[index].velocity_remainder = 0; + scheduler.*.tracks.data[index].position_remainder = 0; + motion_record(scheduler, MotionFrameReason.gesture_handoff, id); + motion_clear_error(scheduler); + return MotionError.none; +} + +fn motion_sample(scheduler: MotionScheduler, id: i64) MotionSample { + let index: i32 = motion_find_index(scheduler, id); + if (index >= 0) { + let track: MotionTrack = scheduler.tracks.data[index]; + return MotionSample{ + .found = 1, + .id = track.id, + .kind = track.kind, + .status = track.status, + .value = track.value, + .velocity = track.velocity, + .target = track.target, + .elapsed_us = track.elapsed_us, + }; + } + return MotionSample{ + .found = 0, + .id = 0, + .kind = MotionKind.spring, + .status = MotionStatus.settled, + .value = 0, + .velocity = 0, + .target = 0, + .elapsed_us = 0, + }; +} + +fn motion_scheduler_hash(scheduler: MotionScheduler) i64 { + let hash: i64 = display_mix(17, scheduler.now_us); + hash = display_mix(hash, scheduler.has_clock as i64); + hash = display_mix(hash, scheduler.refresh_hz); + hash = display_mix(hash, scheduler.frame_interval_us); + hash = display_mix(hash, scheduler.next_sequence); + hash = display_mix(hash, scheduler.last_error as i64); + hash = display_mix(hash, scheduler.error_track); + hash = display_mix(hash, scheduler.tracks.len as i64); + let i: i32 = 0; + while (i < scheduler.tracks.len) { + let track: MotionTrack = scheduler.tracks.data[i]; + hash = display_mix(hash, track.id); + hash = display_mix(hash, track.kind as i64); + hash = display_mix(hash, track.status as i64); + hash = display_mix(hash, track.value); + hash = display_mix(hash, track.velocity); + hash = display_mix(hash, track.target); + hash = display_mix(hash, track.spring.stiffness); + hash = display_mix(hash, track.spring.damping); + hash = display_mix(hash, track.spring.position_epsilon); + hash = display_mix(hash, track.spring.velocity_epsilon); + hash = display_mix(hash, track.elapsed_us); + hash = display_mix(hash, track.remainder_us); + hash = display_mix(hash, track.velocity_remainder); + hash = display_mix(hash, track.position_remainder); + hash = display_mix(hash, track.keyframes.len as i64); + let j: i32 = 0; + while (j < track.keyframes.len) { + hash = display_mix(hash, track.keyframes.data[j].time_us); + hash = display_mix(hash, track.keyframes.data[j].value); + j = j + 1; + } + i = i + 1; + } + hash = display_mix(hash, scheduler.events.len as i64); + i = 0; + while (i < scheduler.events.len) { + let event: MotionEvent = scheduler.events.data[i]; + hash = display_mix(hash, event.sequence); + hash = display_mix(hash, event.reason as i64); + hash = display_mix(hash, event.track_id); + hash = display_mix(hash, event.time_us); + hash = display_mix(hash, event.value); + hash = display_mix(hash, event.velocity); + hash = display_mix(hash, event.target); + i = i + 1; + } + return hash; +} diff --git a/src/replay/replay.zag b/src/replay/replay.zag index 789a4de..e989207 100644 --- a/src/replay/replay.zag +++ b/src/replay/replay.zag @@ -4,6 +4,7 @@ @import("../layout/flex.zag") @import("../semantics/semantics.zag") @import("../render/cpu_raster.zag") +@import("../motion/motion.zag") enum ReplayEventKind { state_i64, @@ -26,6 +27,7 @@ enum ReplayError { clock_regression, backend_transition, input_rejected, + motion_error, scene_error, } @@ -60,6 +62,10 @@ struct ReplayResult { backend_generation: i64, backend_state: ReplayBackendState, input_hash: i64, + motion_hash: i64, + motion_value: i64, + motion_velocity: i64, + motion_status: MotionStatus, semantics_hash: i64, layout_hash: i64, display_hash: i64, @@ -228,6 +234,10 @@ fn replay_result_error(error: ReplayError, event_index: i32, applied: i64) Repla .backend_generation = 0, .backend_state = ReplayBackendState.unavailable, .input_hash = 0, + .motion_hash = 0, + .motion_value = 0, + .motion_velocity = 0, + .motion_status = MotionStatus.settled, .semantics_hash = 0, .layout_hash = 0, .display_hash = 0, @@ -327,6 +337,12 @@ fn replay_layout_hash(layout: FlexResult) i64 { return hash; } +fn replay_runtime_free(router: *InputRouter, tree: *HitTree, motion: *MotionScheduler) void { + input_router_free(router); + hit_tree_free(tree); + motion_scheduler_free(motion); +} + fn replay_build_scene(state_value: i64, backend_generation: i64) ReplayResult { let result: ReplayResult = replay_result_error(ReplayError.none, 0 - 1, 0); @@ -415,6 +431,12 @@ fn replay_execute(tape: ReplayTape) ReplayResult { let state: State[i64] = state_make[i64](1, 0); let tree: HitTree = replay_hit_tree(); let router: InputRouter = input_router_make(); + let motion: MotionScheduler = motion_scheduler_make(120); + _ = motion_set_clock(&motion, 0); + _ = motion_start_spring( + &motion, 900, 0, 0, spring_spec(220, 28), + MotionPreference.full, MotionReducedSubstitution.snap_to_end + ); let clock: i64 = 0; let backend_id: i64 = 0; let backend_generation: i64 = 0; @@ -424,26 +446,37 @@ fn replay_execute(tape: ReplayTape) ReplayResult { let event: ReplayEvent = tape.events.data[i]; if (event.kind == ReplayEventKind.state_i64) { if (event.a != state.id || event.b != state.revision) { - input_router_free(&router); hit_tree_free(&tree); + replay_runtime_free(&router, &tree, &motion); return replay_result_error(ReplayError.stale_state_revision, i, i as i64); } _ = state_set[i64](&state, event.c); + if (motion_retarget( + &motion, 900, event.c * unit_scale(), MotionFrameReason.interrupt, + MotionPreference.full, MotionReducedSubstitution.snap_to_end + ) != MotionError.none) { + replay_runtime_free(&router, &tree, &motion); + return replay_result_error(ReplayError.motion_error, i, i as i64); + } } else if (event.kind == ReplayEventKind.pointer) { let phase: PointerPhase = event.b as PointerPhase; let routed: RouteResult = input_route_pointer(&router, tree, pointer_event(event.a, phase, event.c, event.d)); if (routed.status != RouteStatus.accepted) { - input_router_free(&router); hit_tree_free(&tree); + replay_runtime_free(&router, &tree, &motion); return replay_result_error(ReplayError.input_rejected, i, i as i64); } } else if (event.kind == ReplayEventKind.clock) { if (event.a < clock) { - input_router_free(&router); hit_tree_free(&tree); + replay_runtime_free(&router, &tree, &motion); return replay_result_error(ReplayError.clock_regression, i, i as i64); } + if (motion_tick(&motion, event.a).error != MotionError.none) { + replay_runtime_free(&router, &tree, &motion); + return replay_result_error(ReplayError.motion_error, i, i as i64); + } clock = event.a; } else if (event.kind == ReplayEventKind.backend_activate) { if (backend_state != ReplayBackendState.unavailable) { - input_router_free(&router); hit_tree_free(&tree); + replay_runtime_free(&router, &tree, &motion); return replay_result_error(ReplayError.backend_transition, i, i as i64); } backend_id = event.a; @@ -451,13 +484,13 @@ fn replay_execute(tape: ReplayTape) ReplayResult { backend_state = ReplayBackendState.active; } else if (event.kind == ReplayEventKind.backend_loss) { if (backend_state != ReplayBackendState.active || event.a != backend_id) { - input_router_free(&router); hit_tree_free(&tree); + replay_runtime_free(&router, &tree, &motion); return replay_result_error(ReplayError.backend_transition, i, i as i64); } backend_state = ReplayBackendState.lost; } else if (event.kind == ReplayEventKind.backend_recover) { if (backend_state != ReplayBackendState.lost || event.a != backend_id) { - input_router_free(&router); hit_tree_free(&tree); + replay_runtime_free(&router, &tree, &motion); return replay_result_error(ReplayError.backend_transition, i, i as i64); } backend_generation = backend_generation + 1; @@ -468,7 +501,7 @@ fn replay_execute(tape: ReplayTape) ReplayResult { let scene: ReplayResult = replay_build_scene(state.value, backend_generation); if (scene.error != ReplayError.none) { - input_router_free(&router); hit_tree_free(&tree); + replay_runtime_free(&router, &tree, &motion); return scene; } let result: ReplayResult = scene; @@ -482,6 +515,11 @@ fn replay_execute(tape: ReplayTape) ReplayResult { result.backend_generation = backend_generation; result.backend_state = backend_state; result.input_hash = replay_input_hash(router); + result.motion_hash = motion_scheduler_hash(motion); + let motion_output: MotionSample = motion_sample(motion, 900); + result.motion_value = motion_output.value; + result.motion_velocity = motion_output.velocity; + result.motion_status = motion_output.status; let hash: i64 = display_mix(17, result.state_value); hash = display_mix(hash, result.state_revision); hash = display_mix(hash, result.clock); @@ -489,12 +527,12 @@ fn replay_execute(tape: ReplayTape) ReplayResult { hash = display_mix(hash, result.backend_generation); hash = display_mix(hash, result.backend_state as i64); hash = display_mix(hash, result.input_hash); + hash = display_mix(hash, result.motion_hash); hash = display_mix(hash, result.semantics_hash); hash = display_mix(hash, result.layout_hash); hash = display_mix(hash, result.display_hash); result.output_hash = display_mix(hash, result.cpu_hash); - input_router_free(&router); - hit_tree_free(&tree); + replay_runtime_free(&router, &tree, &motion); return result; } diff --git a/tests/motion_contract.zag b/tests/motion_contract.zag new file mode 100644 index 0000000..a2eb713 --- /dev/null +++ b/tests/motion_contract.zag @@ -0,0 +1,227 @@ +@import("../src/motion/motion.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn timeline() ArrayList[MotionKeyframe] { + let frames: ArrayList[MotionKeyframe] = make[MotionKeyframe](3); + push[MotionKeyframe](&frames, MotionKeyframe{ .time_us = 0, .value = 0 }); + push[MotionKeyframe](&frames, MotionKeyframe{ .time_us = 100000, .value = 10 * unit_scale() }); + push[MotionKeyframe](&frames, MotionKeyframe{ .time_us = 200000, .value = 0 }); + return frames; +} + +fn idle_and_refresh_contract(state: *TestState) void { + let scheduler: MotionScheduler = motion_scheduler_make(60); + let first: MotionTickResult = motion_tick(&scheduler, 1000); + let idle: MotionTickResult = motion_tick(&scheduler, 2000); + expect(state, first.error == MotionError.none && idle.did_work == 0 && + idle.request_next_frame == 0 && scheduler.events.len == 0, + "idle clocks perform no motion work and schedule no phantom frame"); + expect(state, motion_set_refresh_rate(&scheduler, 120) == MotionError.none && + scheduler.refresh_hz == 120 && scheduler.frame_interval_us == 8333, + "refresh-rate changes update the explicit frame deadline"); + expect(state, scheduler.events.len == 1 && + scheduler.events.data[0].reason == MotionFrameReason.refresh_rate_change, + "refresh-rate changes retain an observable frame reason"); + expect(state, motion_set_refresh_rate(&scheduler, 12) == MotionError.invalid_spec && + scheduler.refresh_hz == 120, + "invalid display rates fail without mutating scheduler truth"); + motion_scheduler_free(&scheduler); +} + +fn spring_determinism_contract(state: *TestState) void { + let split: MotionScheduler = motion_scheduler_make(120); + let grouped: MotionScheduler = motion_scheduler_make(120); + _ = motion_set_clock(&split, 0); + _ = motion_set_clock(&grouped, 0); + let spec: SpringSpec = spring_spec(220, 28); + _ = motion_start_spring(&split, 1, 0, 10 * unit_scale(), spec, MotionPreference.full, MotionReducedSubstitution.snap_to_end); + _ = motion_start_spring(&grouped, 1, 0, 10 * unit_scale(), spec, MotionPreference.full, MotionReducedSubstitution.snap_to_end); + _ = motion_tick(&split, 8333); + _ = motion_tick(&split, 16666); + _ = motion_tick(&split, 25000); + _ = motion_tick(&grouped, 25000); + let a: MotionSample = motion_sample(split, 1); + let b: MotionSample = motion_sample(grouped, 1); + expect(state, a.value == b.value && a.velocity == b.velocity && a.status == b.status, + "fixed-step springs are independent of refresh callback grouping"); + expect(state, a.value > 0 && a.value < 10 * unit_scale() && a.velocity > 0, + "spring integration advances position and velocity without teleporting"); + expect(state, motion_needs_frame(split) == 1 && motion_tick(&split, 25000).request_next_frame == 1, + "running motion requests another frame even when a callback repeats the same clock"); + + let replay_a: MotionScheduler = motion_scheduler_make(120); + let replay_b: MotionScheduler = motion_scheduler_make(120); + _ = motion_set_clock(&replay_a, 0); _ = motion_set_clock(&replay_b, 0); + _ = motion_start_spring(&replay_a, 8, 0, 5 * unit_scale(), spec, MotionPreference.full, MotionReducedSubstitution.snap_to_end); + _ = motion_start_spring(&replay_b, 8, 0, 5 * unit_scale(), spec, MotionPreference.full, MotionReducedSubstitution.snap_to_end); + _ = motion_tick(&replay_a, 8333); _ = motion_tick(&replay_b, 8333); + _ = motion_tick(&replay_a, 16666); _ = motion_tick(&replay_b, 16666); + expect(state, motion_scheduler_hash(replay_a) == motion_scheduler_hash(replay_b), + "identical supplied clocks produce identical full motion evidence"); + + let settling: MotionScheduler = motion_scheduler_make(120); + _ = motion_set_clock(&settling, 0); + _ = motion_start_spring(&settling, 9, 0, 5 * unit_scale(), spec, MotionPreference.full, MotionReducedSubstitution.snap_to_end); + _ = motion_tick(&settling, 1000000); + _ = motion_tick(&settling, 2000000); + _ = motion_tick(&settling, 3000000); + _ = motion_tick(&settling, 4000000); + let last: MotionTickResult = motion_tick(&settling, 5000000); + let settled: MotionSample = motion_sample(settling, 9); + let settled_events: i32 = settling.events.len; + let after: MotionTickResult = motion_tick(&settling, 5001000); + expect(state, settled.status == MotionStatus.settled && settled.value == 5 * unit_scale() && + settled.velocity == 0 && last.request_next_frame == 0 && after.did_work == 0 && + settling.events.len == settled_events, + "settled springs snap exactly and produce no continuous idle work"); + motion_scheduler_free(&settling); + motion_scheduler_free(&replay_a); motion_scheduler_free(&replay_b); + motion_scheduler_free(&split); motion_scheduler_free(&grouped); +} + +fn continuity_contract(state: *TestState) void { + let scheduler: MotionScheduler = motion_scheduler_make(120); + _ = motion_set_clock(&scheduler, 0); + _ = motion_start_spring(&scheduler, 2, 0, 12 * unit_scale(), spring_spec(240, 30), MotionPreference.full, MotionReducedSubstitution.snap_to_end); + _ = motion_tick(&scheduler, 50000); + let before: MotionSample = motion_sample(scheduler, 2); + _ = motion_retarget(&scheduler, 2, 18 * unit_scale(), MotionFrameReason.interrupt, MotionPreference.full, MotionReducedSubstitution.snap_to_end); + let interrupted: MotionSample = motion_sample(scheduler, 2); + expect(state, interrupted.value == before.value && interrupted.velocity == before.velocity && + interrupted.target == 18 * unit_scale(), + "interruption preserves exact position and incoming velocity"); + _ = motion_retarget(&scheduler, 2, 0, MotionFrameReason.reverse, MotionPreference.full, MotionReducedSubstitution.snap_to_end); + let reversed: MotionSample = motion_sample(scheduler, 2); + expect(state, reversed.value == before.value && reversed.velocity == before.velocity && reversed.target == 0, + "reversal preserves exact position and incoming velocity"); + _ = motion_retarget(&scheduler, 2, 20 * unit_scale(), MotionFrameReason.resize, MotionPreference.full, MotionReducedSubstitution.snap_to_end); + let resized: MotionSample = motion_sample(scheduler, 2); + expect(state, resized.value == before.value && resized.velocity == before.velocity && resized.target == 20 * unit_scale(), + "resize retargeting preserves continuity while changing destination"); + _ = motion_gesture_handoff(&scheduler, 2, 7 * unit_scale(), 90 * unit_scale(), 15 * unit_scale()); + let handed: MotionSample = motion_sample(scheduler, 2); + expect(state, handed.value == 7 * unit_scale() && handed.velocity == 90 * unit_scale() && + handed.target == 15 * unit_scale() && handed.status == MotionStatus.running, + "gesture handoff transfers position and velocity exactly"); + expect(state, scheduler.events.data[scheduler.events.len - 4].reason == MotionFrameReason.interrupt && + scheduler.events.data[scheduler.events.len - 3].reason == MotionFrameReason.reverse && + scheduler.events.data[scheduler.events.len - 2].reason == MotionFrameReason.resize && + scheduler.events.data[scheduler.events.len - 1].reason == MotionFrameReason.gesture_handoff, + "continuity changes retain ordered semantic frame reasons"); + motion_scheduler_free(&scheduler); +} + +fn keyframe_contract(state: *TestState) void { + let frames: ArrayList[MotionKeyframe] = timeline(); + let scheduler: MotionScheduler = motion_scheduler_make(60); + _ = motion_set_clock(&scheduler, 0); + _ = motion_start_keyframes(&scheduler, 3, frames, MotionPreference.full, MotionReducedSubstitution.snap_to_end); + frames.data[1].value = 999; + _ = motion_tick(&scheduler, 50000); + let rising: MotionSample = motion_sample(scheduler, 3); + expect(state, rising.value == 5 * unit_scale() && rising.velocity == 100 * unit_scale(), + "keyframes interpolate exact position and segment velocity from owned data"); + _ = motion_tick(&scheduler, 150000); + let falling: MotionSample = motion_sample(scheduler, 3); + expect(state, falling.value == 5 * unit_scale() && falling.velocity == -100 * unit_scale(), + "keyframe direction changes preserve exact deterministic velocity"); + let final_tick: MotionTickResult = motion_tick(&scheduler, 200000); + let finished: MotionSample = motion_sample(scheduler, 3); + expect(state, finished.value == 0 && finished.velocity == 0 && finished.status == MotionStatus.settled && + final_tick.request_next_frame == 0, + "keyframe timelines settle exactly and stop scheduling frames"); + let event_count: i32 = scheduler.events.len; + let idle: MotionTickResult = motion_tick(&scheduler, 201000); + expect(state, idle.did_work == 0 && scheduler.events.len == event_count, + "settled timelines remain completely idle"); + free[MotionKeyframe](&frames); + motion_scheduler_free(&scheduler); +} + +fn reduced_motion_contract(state: *TestState) void { + let scheduler: MotionScheduler = motion_scheduler_make(60); + _ = motion_set_clock(&scheduler, 0); + _ = motion_start_spring(&scheduler, 4, 0, 20 * unit_scale(), spring_spec(220, 28), MotionPreference.reduced, MotionReducedSubstitution.snap_to_end); + let snapped: MotionSample = motion_sample(scheduler, 4); + expect(state, snapped.value == 20 * unit_scale() && snapped.status == MotionStatus.settled && + scheduler.events.data[0].reason == MotionFrameReason.reduced_motion, + "reduced motion uses an explicit snap substitution for spatial movement"); + + let fade: ArrayList[MotionKeyframe] = make[MotionKeyframe](2); + push[MotionKeyframe](&fade, MotionKeyframe{ .time_us = 0, .value = 0 }); + push[MotionKeyframe](&fade, MotionKeyframe{ .time_us = 80000, .value = unit_scale() }); + _ = motion_start_keyframes(&scheduler, 5, fade, MotionPreference.reduced, MotionReducedSubstitution.opacity_fade); + _ = motion_tick(&scheduler, 40000); + let faded: MotionSample = motion_sample(scheduler, 5); + expect(state, faded.value == unit_scale() / 2 && faded.status == MotionStatus.running, + "reduced motion can use a caller-authored opacity substitution timeline"); + free[MotionKeyframe](&fade); + motion_scheduler_free(&scheduler); +} + +fn failure_contract(state: *TestState) void { + let scheduler: MotionScheduler = motion_scheduler_make(60); + expect(state, motion_start_spring(&scheduler, 1, 0, 64, spring_spec(220, 28), MotionPreference.full, MotionReducedSubstitution.snap_to_end) == MotionError.invalid_spec, + "tracks cannot start without an authoritative supplied clock"); + _ = motion_set_clock(&scheduler, 1000); + _ = motion_start_spring(&scheduler, 1, 0, 64, spring_spec(220, 28), MotionPreference.full, MotionReducedSubstitution.snap_to_end); + expect(state, motion_start_spring(&scheduler, 1, 0, 64, spring_spec(220, 28), MotionPreference.full, MotionReducedSubstitution.snap_to_end) == MotionError.duplicate_id && scheduler.tracks.len == 1, + "duplicate stable motion IDs fail before allocation"); + let before: MotionSample = motion_sample(scheduler, 1); + expect(state, motion_set_clock(&scheduler, 2000) == MotionError.clock_advance_requires_tick && + scheduler.now_us == 1000, + "running motion cannot be skipped by advancing the clock outside tick"); + expect(state, motion_tick(&scheduler, 999).error == MotionError.clock_regression && + scheduler.now_us == 1000 && motion_sample(scheduler, 1).value == before.value, + "clock regression fails before scheduler or track mutation"); + expect(state, motion_tick(&scheduler, 60001001).error == MotionError.clock_step_too_large && + scheduler.now_us == 1000, + "unbounded suspend jumps fail visibly before expensive catch-up"); + expect(state, motion_retarget(&scheduler, 99, 0, MotionFrameReason.interrupt, MotionPreference.full, MotionReducedSubstitution.snap_to_end) == MotionError.missing_track, + "retargeting a missing stable ID fails visibly"); + + let malformed: ArrayList[MotionKeyframe] = make[MotionKeyframe](2); + push[MotionKeyframe](&malformed, MotionKeyframe{ .time_us = 100, .value = 0 }); + push[MotionKeyframe](&malformed, MotionKeyframe{ .time_us = 50, .value = 64 }); + expect(state, motion_start_keyframes(&scheduler, 2, malformed, MotionPreference.full, MotionReducedSubstitution.snap_to_end) == MotionError.invalid_keyframes && scheduler.tracks.len == 1, + "malformed keyframe order fails before ownership transfer"); + free[MotionKeyframe](&malformed); + + let spatial_fade: ArrayList[MotionKeyframe] = make[MotionKeyframe](2); + push[MotionKeyframe](&spatial_fade, MotionKeyframe{ .time_us = 0, .value = 0 }); + push[MotionKeyframe](&spatial_fade, MotionKeyframe{ .time_us = 80000, .value = 2 * unit_scale() }); + expect(state, motion_start_keyframes(&scheduler, 3, spatial_fade, MotionPreference.reduced, MotionReducedSubstitution.opacity_fade) == MotionError.invalid_keyframes, + "reduced opacity substitution rejects spatial values masquerading as a fade"); + expect(state, motion_start_spring(&scheduler, 4, 0, 64, spring_spec(220, 28), MotionPreference.reduced, MotionReducedSubstitution.opacity_fade) == MotionError.invalid_spec, + "spatial springs require an explicit snap reduced-motion substitution"); + free[MotionKeyframe](&spatial_fade); + motion_scheduler_free(&scheduler); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + idle_and_refresh_contract(&state); + spring_determinism_contract(&state); + continuity_contract(&state); + keyframe_contract(&state); + reduced_motion_contract(&state); + failure_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Motion contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tests/replay_contract.zag b/tests/replay_contract.zag index 48a3938..e8b6ed5 100644 --- a/tests/replay_contract.zag +++ b/tests/replay_contract.zag @@ -20,11 +20,11 @@ fn complete_tape(final_value: i64, pointer_x: i64) ReplayTape { _ = replay_tape_push(&tape, replay_pointer_event(9, PointerPhase.down, pointer_x, unit_scale())); _ = replay_tape_push(&tape, replay_pointer_event(9, PointerPhase.move, pointer_x + unit_scale(), unit_scale())); _ = replay_tape_push(&tape, replay_pointer_event(9, PointerPhase.up, pointer_x + unit_scale(), unit_scale())); - _ = replay_tape_push(&tape, replay_clock_event(10)); + _ = replay_tape_push(&tape, replay_clock_event(10000)); _ = replay_tape_push(&tape, replay_state_event(1, 1, final_value)); _ = replay_tape_push(&tape, replay_backend_loss_event(7)); _ = replay_tape_push(&tape, replay_backend_recover_event(7)); - _ = replay_tape_push(&tape, replay_clock_event(20)); + _ = replay_tape_push(&tape, replay_clock_event(20000)); _ = replay_tape_seal(&tape); return tape; } @@ -35,6 +35,9 @@ fn results_equal(a: ReplayResult, b: ReplayResult) i32 { a.state_revision == b.state_revision && a.clock == b.clock && a.backend_id == b.backend_id && a.backend_generation == b.backend_generation && a.backend_state == b.backend_state && a.input_hash == b.input_hash && + a.motion_hash == b.motion_hash && + a.motion_value == b.motion_value && a.motion_velocity == b.motion_velocity && + a.motion_status == b.motion_status && a.semantics_hash == b.semantics_hash && a.layout_hash == b.layout_hash && a.display_hash == b.display_hash && a.cpu_hash == b.cpu_hash && a.output_hash == b.output_hash) as i32; @@ -48,14 +51,17 @@ fn deterministic_contract(state: *TestState) void { "sealed tape preserves one canonical ordered event stream"); expect(state, first.error == ReplayError.none && first.events_applied == 11, "complete tape applies every event without hidden skips"); - expect(state, first.state_value == 2 && first.state_revision == 2 && first.clock == 20, + expect(state, first.state_value == 2 && first.state_revision == 2 && first.clock == 20000, "state revisions and monotonic clock replay exactly"); expect(state, first.backend_id == 7 && first.backend_generation == 2 && first.backend_state == ReplayBackendState.active, "backend activation loss and recovery remain observable in final truth"); - expect(state, first.input_hash != 0 && first.semantics_hash != 0 && first.layout_hash != 0 && + expect(state, first.input_hash != 0 && first.motion_hash != 0 && first.semantics_hash != 0 && first.layout_hash != 0 && first.display_hash != 0 && first.cpu_hash != 0 && first.output_hash != 0, - "replay regenerates input semantics layout display and CPU identities"); + "replay regenerates input motion semantics layout display and CPU identities"); + expect(state, first.motion_value > 0 && first.motion_velocity > 0 && + first.motion_status == MotionStatus.running, + "replayed clock samples advance interruptible motion state"); expect(state, results_equal(first, second) == 1, "two executions of the same sealed tape are bit-identical"); replay_tape_free(&tape); @@ -145,10 +151,10 @@ fn differential_contract(state: *TestState) void { let a: ReplayResult = replay_execute(baseline); let b: ReplayResult = replay_execute(changed_state); let c: ReplayResult = replay_execute(changed_pointer); - expect(state, a.output_hash != b.output_hash && a.semantics_hash != b.semantics_hash && + expect(state, a.output_hash != b.output_hash && a.motion_hash != b.motion_hash && a.semantics_hash != b.semantics_hash && a.layout_hash != b.layout_hash && a.display_hash != b.display_hash && a.cpu_hash != b.cpu_hash, - "state changes propagate through semantics Flex display and CPU output"); - expect(state, a.input_hash != c.input_hash && a.semantics_hash == c.semantics_hash && + "state changes propagate through motion semantics Flex display and CPU output"); + expect(state, a.input_hash != c.input_hash && a.motion_hash == c.motion_hash && a.semantics_hash == c.semantics_hash && a.layout_hash == c.layout_hash && a.display_hash == c.display_hash && a.cpu_hash == c.cpu_hash && a.output_hash != c.output_hash, "pointer differences change input identity without contaminating scene generation"); diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 8623cef..a74d0ac 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -32,5 +32,7 @@ cd "$root" "$tmp/input-contract" "$znc" tests/replay_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/replay-contract" "$tmp/replay-contract" +"$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" +"$tmp/motion-contract" -printf 'headless test: PASS (state, reconciliation, constraints, Flex, semantics, Talkback, display lists, CPU raster, input, and replay)\n' +printf 'headless test: PASS (state, reconciliation, constraints, Flex, semantics, Talkback, display lists, CPU raster, input, replay, and motion)\n' From 240d0f631f6d7c320d4d67efc9ca0c7f4379e953 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 07:08:34 -0700 Subject: [PATCH 016/103] Implement adaptive wrapped Flex foundation --- CHANGELOG.md | 4 + README.md | 16 +- docs/architecture/README.md | 7 + docs/architecture/flex.md | 96 ++++++++ src/layout/adaptive.zag | 116 +++++++++ src/layout/flex.zag | 71 +++++- src/layout/flex_wrap.zag | 387 +++++++++++++++++++++++++++++++ src/replay/replay.zag | 59 ++--- tests/flex_adaptive_contract.zag | 320 +++++++++++++++++++++++++ tests/flex_contract.zag | 26 +++ tests/replay_contract.zag | 5 + tools/test-headless.sh | 2 + 12 files changed, 1073 insertions(+), 36 deletions(-) create mode 100644 docs/architecture/flex.md create mode 100644 src/layout/adaptive.zag create mode 100644 src/layout/flex_wrap.zag create mode 100644 tests/flex_adaptive_contract.zag diff --git a/CHANGELOG.md b/CHANGELOG.md index 083191f..64983e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ include a generated capability record and exact Zag compiler revision. - Experimental keyed reconciliation with stable reorder identity and fail-visible duplicate-key handling. - Deterministic fixed-point geometry, constraints, and single-line Flex. +- Experimental adaptive Flex primitives with tokenized density-aware spacing, + physical safe areas, text-scale-aware breakpoints, deterministic row and + column wrapping, RTL line bands, baseline-safe extents, per-line growth, + typed overflow, malformed-input rejection, and layout identity. - An experimental owned semantics tree with roles, actions, focus order, live regions, ranges, selection, text navigation, and stable generated IDs. - The first in-process Zagkit Talkback dispatcher and machine-readable protocol diff --git a/README.md b/README.md index 94c39aa..b2c8ae9 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,23 @@ inventory, benchmark scene coverage, and the 1.0 block. The compiled headless contract currently provides revisioned `State`, action-producing `Binding`, inherited integer environment values, exact per-node state-read records, fail-visible keyed reconciliation, deterministic -fixed-point geometry, and single-line Flex. These APIs are experimental. Typed +fixed-point geometry, and single-line or wrapped Flex. These APIs are +experimental. Typed environment values, reconciliation cancellation, child ownership, replay -serialization, intrinsic measurement, wrapping, grid, overlay, and breakpoints +serialization, intrinsic measurement, grid, overlay, and breakpoint policies remain open; the corresponding Milestone 2 checklist items are not complete. +Flex now also provides an experimental primitive spacing scale, compact, +standard, and touch density resolution, physical safe-area composition, +text-scale-aware compact/medium/expanded breakpoints, and deterministic row or +column wrapping. Wrapped lines preserve logical stable-ID order while resolving +physical RTL placement, per-line growth and shrinkage, baseline-safe extents, +line alignment, overflow truth, and layout identity. These are headless +placement primitives rather than the still-unselected visual design language. +Grid, overlay, intrinsic measurement, exact invalidation rules, and the full +adaptive matrix remain open, so `G2-FLEX` and `G2-FLEX-RTL` are not complete. +See the [Flex contract](docs/architecture/flex.md). + The semantics slice owns copied names and values, stable keys, explicit action capabilities, deterministic focus order, live-region state, ranges, selection, and text-navigation bounds. Invalid parents, duplicate IDs and focus order, diff --git a/docs/architecture/README.md b/docs/architecture/README.md index adca707..f4fd38f 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -28,6 +28,7 @@ Normative decisions: Detailed experimental contracts: +- [Flex placement and adaptive spacing](flex.md) - [motion scheduler and tracks](motion.md) The first experimental compiled slice now fixes the initial Zag shapes for @@ -39,6 +40,12 @@ keys. Ownership, threading, serialization, typed environment values, cancellation and replay serialization are still open contracts rather than stable API. +Flex extends fixed-point constraints with deterministic single-line and wrapped +row or column placement. Its primitive spacing scale adapts to density, safe +areas, text scale, breakpoints, and physical RTL without changing logical ID +order. Wrapped output retains line ranges, exact placement identity, and +fail-visible overflow or malformed input. See [flex.md](flex.md). + The parallel experimental `SemanticsTree` retains owned names and values plus roles, actions, focus order, live regions, ranges, selection, and text navigation. Its stable `NodeKey` identity is shared with rendering, so future diff --git a/docs/architecture/flex.md b/docs/architecture/flex.md new file mode 100644 index 0000000..dfe3c82 --- /dev/null +++ b/docs/architecture/flex.md @@ -0,0 +1,96 @@ +# Experimental Flex placement contract + +Flex is Zagkit's public placement and spacing foundation. It works entirely in +signed 26.6 logical units and preserves stable item IDs in logical order. The +current implementation covers single-line rows and columns plus deterministic +multi-line wrapping; it is not yet the complete `G2-FLEX` contract. + +## Primitive spacing scale + +`FlexSpacingToken` resolves to this standard-density scale: + +| Token | Logical pixels | +|---|---:| +| `none` | 0 | +| `micro` | 2 | +| `tiny` | 4 | +| `small` | 8 | +| `medium` | 12 | +| `large` | 16 | +| `xlarge` | 24 | +| `xxlarge` | 32 | + +Compact density resolves each primitive to three quarters of its standard +value. Touch density resolves to five quarters. All results remain exact 26.6 +values. Text scale does not silently multiply gaps; it affects adaptive +composition through breakpoint selection. + +These are experimental layout primitives, not accepted component styling. The +three-direction visual review will decide which semantic component tokens map +to the scale before visual component production begins. + +## Safe areas and breakpoints + +`FlexAdaptiveContext` carries viewport size, physical safe-area insets, +density, text scale, and layout direction. Safe areas are normalized before +arithmetic and can be added to tokenized padding. They remain physical: left +and right do not swap under RTL. + +Breakpoints use safe content width divided by effective text scale: + +- compact below 600 logical pixels; +- medium from 600 through 1,023 logical pixels; +- expanded from 1,024 logical pixels. + +Text scale is bounded from 1x through 4x for breakpoint calculation. Larger +text therefore selects a roomier composition without distorting the primitive +spacing scale. + +## Wrapping + +`FlexWrapStyle` adds line gap, line alignment, and a wrapping switch to a +normal `FlexStyle`. Row and column wrapping use the constrained inner main +extent after padding. Items remain in logical insertion order and retain stable +IDs across line changes. + +Each line independently runs the same grow, shrink, main alignment, cross +alignment, and baseline rules as single-line Flex. Baseline lines reserve the +largest ascent plus largest descent, avoiding clipping when equal-height items +have different baselines. + +Line alignment supports start, center, end, space-between, space-around, +space-evenly, and stretch. RTL rows place logical starts at the physical right. +RTL columns also advance line bands from right to left while preserving item +order and physical safe-area edges. + +`FlexWrapResult` owns placements and line ranges. It reports main and cross +overflow separately, duplicate IDs, whether wrapping occurred, and a +deterministic layout hash over line and placement truth. + +## Failure and ownership + +Single-line and wrapped layout reject malformed IDs or geometry, unsafe style +extents, and arithmetic that would exceed Zagkit's bounded logical extent. +Single-line failures return `FlexError`; wrapping additionally rejects more than +one million input items and returns `FlexWrapError`. Both identify the exact +item when applicable and return empty placement ownership. Wrapped failures +also retain a deterministic failure identity. No partial placement is reported +as success. + +Grow and shrink weights are nonnegative and bounded to 4,096 so weighted +distribution cannot overflow the fixed-point extent contract. + +Call `flex_wrap_free` for every result, including failures. It releases both +the placement and line arrays. + +## Current boundary + +The following remain required before either Flex checklist item can complete: + +- intrinsic measurement and priorities; +- grid and overlay placement; +- safe-area and density matrices driven by real platform shells; +- exact state-read and rule reasons for every layout invalidation; +- layout-transition orchestration; +- full RTL, large-text, and adaptive component conformance; +- visual-direction acceptance and screenshot goldens. diff --git a/src/layout/adaptive.zag b/src/layout/adaptive.zag new file mode 100644 index 0000000..b728906 --- /dev/null +++ b/src/layout/adaptive.zag @@ -0,0 +1,116 @@ +@import("flex.zag") + +enum FlexSpacingToken { none, micro, tiny, small, medium, large, xlarge, xxlarge } +enum FlexDensity { compact, standard, touch } +enum FlexBreakpoint { compact, medium, expanded } + +struct FlexAdaptiveContext { + viewport: Size, + safe_area: Insets, + density: FlexDensity, + text_scale: i64, + direction: LayoutDirection, +} + +fn flex_adaptive_context(viewport: Size) FlexAdaptiveContext { + return FlexAdaptiveContext{ + .viewport = viewport, + .safe_area = insets_all(0), + .density = FlexDensity.standard, + .text_scale = unit_scale(), + .direction = LayoutDirection.ltr, + }; +} + +fn flex_spacing_base(token: FlexSpacingToken) i64 { + return switch (token) { + .none => 0, + .micro => 2 * unit_scale(), + .tiny => 4 * unit_scale(), + .small => 8 * unit_scale(), + .medium => 12 * unit_scale(), + .large => 16 * unit_scale(), + .xlarge => 24 * unit_scale(), + .xxlarge => 32 * unit_scale(), + }; +} + +fn flex_spacing(token: FlexSpacingToken, density: FlexDensity) i64 { + let value: i64 = flex_spacing_base(token); + return switch (density) { + .compact => (value * 3) / 4, + .standard => value, + .touch => (value * 5) / 4, + }; +} + +fn flex_safe_extent(value: i64) i64 { + if (value < 0) { return 0; } + if (value > unbounded_extent() / 4) { return unbounded_extent() / 4; } + return value; +} + +fn flex_safe_insets(raw: Insets) Insets { + let value: Insets = raw; + value.top = flex_safe_extent(value.top); + value.right = flex_safe_extent(value.right); + value.bottom = flex_safe_extent(value.bottom); + value.left = flex_safe_extent(value.left); + return value; +} + +fn flex_insets_add(a: Insets, b: Insets) Insets { + return Insets{ + .top = flex_safe_extent(a.top + b.top), + .right = flex_safe_extent(a.right + b.right), + .bottom = flex_safe_extent(a.bottom + b.bottom), + .left = flex_safe_extent(a.left + b.left), + }; +} + +fn flex_content_size(context: FlexAdaptiveContext) Size { + let safe: Insets = flex_safe_insets(context.safe_area); + let viewport_width: i64 = context.viewport.width; + let viewport_height: i64 = context.viewport.height; + if (viewport_width < 0) { viewport_width = 0; } + if (viewport_height < 0) { viewport_height = 0; } + if (viewport_width > unbounded_extent()) { viewport_width = unbounded_extent(); } + if (viewport_height > unbounded_extent()) { viewport_height = unbounded_extent(); } + let width: i64 = viewport_width - safe.left - safe.right; + let height: i64 = viewport_height - safe.top - safe.bottom; + if (width < 0) { width = 0; } + if (height < 0) { height = 0; } + return size(width, height); +} + +fn flex_effective_text_scale(context: FlexAdaptiveContext) i64 { + let scale: i64 = context.text_scale; + if (scale < unit_scale()) { scale = unit_scale(); } + if (scale > unit_scale() * 4) { scale = unit_scale() * 4; } + return scale; +} + +fn flex_breakpoint(context: FlexAdaptiveContext) FlexBreakpoint { + let content: Size = flex_content_size(context); + let effective_width: i64 = (content.width * unit_scale()) / flex_effective_text_scale(context); + if (effective_width < 600 * unit_scale()) { return FlexBreakpoint.compact; } + if (effective_width < 1024 * unit_scale()) { return FlexBreakpoint.medium; } + return FlexBreakpoint.expanded; +} + +fn flex_adaptive_style( + axis: FlexAxis, + gap: FlexSpacingToken, + padding: FlexSpacingToken, + context: FlexAdaptiveContext, + include_safe_area: i32, +) FlexStyle { + let pad: i64 = flex_spacing(padding, context.density); + let insets: Insets = insets_all(pad); + if (include_safe_area != 0) { + insets = flex_insets_add(insets, flex_safe_insets(context.safe_area)); + } + let style: FlexStyle = flex_style(axis, flex_spacing(gap, context.density), insets); + style.direction = context.direction; + return style; +} diff --git a/src/layout/flex.zag b/src/layout/flex.zag index 46a54cd..d389ce8 100644 --- a/src/layout/flex.zag +++ b/src/layout/flex.zag @@ -5,6 +5,7 @@ enum FlexAxis { row, column } enum LayoutDirection { ltr, rtl } enum MainAlignment { start, center, end, space_between, space_around, space_evenly } enum CrossAlignment { start, center, end, stretch, baseline } +enum FlexError { none, invalid_constraints, invalid_style, invalid_item, arithmetic_overflow } struct FlexItem { id: i64, @@ -37,6 +38,8 @@ struct FlexResult { container: Size, overflow: i64, duplicate_ids: i32, + error: FlexError, + error_index: i32, } fn flex_item(id: i64, basis: i64, cross: i64) FlexItem { @@ -69,6 +72,30 @@ fn flex_free(result: *FlexResult) void { free[FlexPlacement](&result.*.placements); } +fn flex_error_result(error: FlexError, error_index: i32) FlexResult { + return FlexResult{ + .placements = make[FlexPlacement](1), + .container = size(0, 0), + .overflow = 0, + .duplicate_ids = 0, + .error = error, + .error_index = error_index, + }; +} + +fn flex_checked_add(a: i64, b: i64) i64 { + if (a < 0 || b < 0 || a > unbounded_extent() - b) { return 0 - 1; } + return a + b; +} + +fn flex_item_valid(item: FlexItem) i32 { + return (item.id > 0 && item.basis >= 0 && item.basis <= unbounded_extent() && + item.grow >= 0 && item.grow <= 4096 && item.shrink >= 0 && item.shrink <= 4096 && + item.min_main >= 0 && item.max_main >= item.min_main && item.max_main <= unbounded_extent() && + item.cross >= 0 && item.cross <= unbounded_extent() / 4 && + item.baseline >= 0 && item.baseline <= item.cross) as i32; +} + fn flex_main_min(c: Constraints, axis: FlexAxis) i64 { return switch (axis) { .row => c.min_width, .column => c.min_height }; } @@ -113,6 +140,20 @@ fn flex_item_main(item: FlexItem) i64 { fn flex_layout(items: ArrayList[FlexItem], raw_constraints: Constraints, raw_style: FlexStyle) FlexResult { let constraints: Constraints = normalize_constraints(raw_constraints); let style: FlexStyle = raw_style; + if (constraints.min_width > unbounded_extent() || constraints.max_width > unbounded_extent() || + constraints.min_height > unbounded_extent() || constraints.max_height > unbounded_extent()) { + return flex_error_result(FlexError.invalid_constraints, 0 - 1); + } + if (style.gap > unbounded_extent() / 4 || style.padding.top > unbounded_extent() / 4 || + style.padding.right > unbounded_extent() / 4 || style.padding.bottom > unbounded_extent() / 4 || + style.padding.left > unbounded_extent() / 4) { + return flex_error_result(FlexError.invalid_style, 0 - 1); + } + let invalid_index: i32 = 0; + while (invalid_index < items.len && flex_item_valid(items.data[invalid_index]) != 0) { + invalid_index = invalid_index + 1; + } + if (invalid_index < items.len) { return flex_error_result(FlexError.invalid_item, invalid_index); } if (style.gap < 0) { style.gap = 0; } if (style.padding.top < 0) { style.padding.top = 0; } if (style.padding.right < 0) { style.padding.right = 0; } @@ -122,15 +163,22 @@ fn flex_layout(items: ArrayList[FlexItem], raw_constraints: Constraints, raw_sty let main_padding: i64 = flex_main_padding(style.padding, style.axis); let cross_padding: i64 = flex_cross_padding(style.padding, style.axis); let gap_total: i64 = 0; - if (items.len > 1) { gap_total = style.gap * ((items.len - 1) as i64); } + let gap_index: i32 = 1; + while (gap_index < items.len) { + gap_total = flex_checked_add(gap_total, style.gap); + if (gap_total < 0) { return flex_error_result(FlexError.arithmetic_overflow, gap_index); } + gap_index = gap_index + 1; + } - let base_total: i64 = main_padding + gap_total; + let base_total: i64 = flex_checked_add(main_padding, gap_total); + if (base_total < 0) { return flex_error_result(FlexError.arithmetic_overflow, 0 - 1); } let desired_cross: i64 = cross_padding; let grow_total: i64 = 0; let shrink_total: i64 = 0; let i: i32 = 0; while (i < items.len) { - base_total = base_total + flex_item_main(items.data[i]); + base_total = flex_checked_add(base_total, flex_item_main(items.data[i])); + if (base_total < 0) { return flex_error_result(FlexError.arithmetic_overflow, i); } if (items.data[i].grow > 0) { grow_total = grow_total + items.data[i].grow; } if (items.data[i].shrink > 0) { shrink_total = shrink_total + items.data[i].shrink; } let item_cross: i64 = items.data[i].cross; @@ -187,9 +235,20 @@ fn flex_layout(items: ArrayList[FlexItem], raw_constraints: Constraints, raw_sty } } - let used_main: i64 = main_padding + gap_total; + let used_main: i64 = flex_checked_add(main_padding, gap_total); + if (used_main < 0) { + free[i64](&main_sizes); + return flex_error_result(FlexError.arithmetic_overflow, 0 - 1); + } i = 0; - while (i < main_sizes.len) { used_main = used_main + main_sizes.data[i]; i = i + 1; } + while (i < main_sizes.len) { + used_main = flex_checked_add(used_main, main_sizes.data[i]); + if (used_main < 0) { + free[i64](&main_sizes); + return flex_error_result(FlexError.arithmetic_overflow, i); + } + i = i + 1; + } let remaining: i64 = container_main - used_main; let positive_remaining: i64 = remaining; if (positive_remaining < 0) { positive_remaining = 0; } @@ -273,5 +332,7 @@ fn flex_layout(items: ArrayList[FlexItem], raw_constraints: Constraints, raw_sty .container = output_size, .overflow = overflow, .duplicate_ids = flex_has_duplicate_id(items), + .error = FlexError.none, + .error_index = 0 - 1, }; } diff --git a/src/layout/flex_wrap.zag b/src/layout/flex_wrap.zag new file mode 100644 index 0000000..55fc9cb --- /dev/null +++ b/src/layout/flex_wrap.zag @@ -0,0 +1,387 @@ +@import("std:list") +@import("adaptive.zag") + +enum FlexLineAlignment { start, center, end, space_between, space_around, space_evenly, stretch } +enum FlexWrapError { none, too_many_items, invalid_item, invalid_style, arithmetic_overflow } + +struct FlexWrapStyle { + flex: FlexStyle, + line_gap: i64, + line_alignment: FlexLineAlignment, + wrap: i32, +} + +struct FlexLine { + start: i32, + count: i32, + cross_extent: i64, + cross_offset: i64, +} + +struct FlexWrapResult { + placements: ArrayList[FlexPlacement], + lines: ArrayList[FlexLine], + container: Size, + overflow_main: i64, + overflow_cross: i64, + duplicate_ids: i32, + wrapped: i32, + layout_hash: i64, + error: FlexWrapError, + error_index: i32, +} + +fn flex_wrap_style(style: FlexStyle, line_gap: i64) FlexWrapStyle { + let actual_gap: i64 = line_gap; + if (actual_gap < 0) { actual_gap = 0; } + return FlexWrapStyle{ + .flex = style, + .line_gap = actual_gap, + .line_alignment = FlexLineAlignment.start, + .wrap = 1, + }; +} + +fn flex_wrap_free(result: *FlexWrapResult) void { + free[FlexPlacement](&result.*.placements); + free[FlexLine](&result.*.lines); +} + +fn flex_wrap_empty_result(error: FlexWrapError, error_index: i32) FlexWrapResult { + let result: FlexWrapResult = FlexWrapResult{ + .placements = make[FlexPlacement](1), + .lines = make[FlexLine](1), + .container = size(0, 0), + .overflow_main = 0, + .overflow_cross = 0, + .duplicate_ids = 0, + .wrapped = 0, + .layout_hash = 0, + .error = error, + .error_index = error_index, + }; + result.layout_hash = flex_wrap_hash(result); + return result; +} + +fn flex_wrap_checked_add(a: i64, b: i64) i64 { + if (a < 0 || b < 0 || a > unbounded_extent() - b) { return 0 - 1; } + return a + b; +} + +fn flex_wrap_item_valid(item: FlexItem) i32 { + return (item.id > 0 && item.basis >= 0 && item.basis <= unbounded_extent() && + item.min_main >= 0 && item.max_main >= item.min_main && item.max_main <= unbounded_extent() && + item.grow >= 0 && item.grow <= 4096 && item.shrink >= 0 && item.shrink <= 4096 && + item.cross >= 0 && item.cross <= unbounded_extent() / 4 && + item.baseline >= 0 && item.baseline <= item.cross) as i32; +} + +fn flex_wrap_mix(hash: i64, value: i64) i64 { + return (hash * 1099511628211) ^ value; +} + +fn flex_wrap_validate(items: ArrayList[FlexItem], constraints: Constraints, style: FlexWrapStyle) FlexWrapError { + if (items.len > 1000000) { return FlexWrapError.too_many_items; } + if (constraints.min_width > unbounded_extent() || constraints.max_width > unbounded_extent() || + constraints.min_height > unbounded_extent() || constraints.max_height > unbounded_extent() || + style.flex.gap > unbounded_extent() / 4 || style.line_gap > unbounded_extent() / 4 || + style.flex.padding.top > unbounded_extent() / 4 || + style.flex.padding.right > unbounded_extent() / 4 || + style.flex.padding.bottom > unbounded_extent() / 4 || + style.flex.padding.left > unbounded_extent() / 4) { + return FlexWrapError.invalid_style; + } + let i: i32 = 0; + while (i < items.len) { + if (flex_wrap_item_valid(items.data[i]) == 0) { return FlexWrapError.invalid_item; } + i = i + 1; + } + return FlexWrapError.none; +} + +fn flex_line_cross_extent(items: ArrayList[FlexItem], start: i32, count: i32, style: FlexStyle) i64 { + let extent: i64 = 0; + let max_baseline: i64 = 0; + let max_descent: i64 = 0; + let i: i32 = start; + while (i < start + count) { + let cross: i64 = items.data[i].cross; + if (cross > extent) { extent = cross; } + if (style.axis == FlexAxis.row && style.cross_alignment == CrossAlignment.baseline) { + let baseline: i64 = clamp_i64(items.data[i].baseline, 0, cross); + let descent: i64 = cross - baseline; + if (baseline > max_baseline) { max_baseline = baseline; } + if (descent > max_descent) { max_descent = descent; } + } + i = i + 1; + } + if (max_baseline + max_descent > extent) { extent = max_baseline + max_descent; } + return extent; +} + +fn flex_wrap_hash(result: FlexWrapResult) i64 { + let hash: i64 = flex_wrap_mix(flex_wrap_mix(17, result.container.width), result.container.height); + hash = flex_wrap_mix(hash, result.overflow_main); + hash = flex_wrap_mix(hash, result.overflow_cross); + hash = flex_wrap_mix(hash, result.duplicate_ids as i64); + hash = flex_wrap_mix(hash, result.wrapped as i64); + hash = flex_wrap_mix(hash, result.error as i64); + hash = flex_wrap_mix(hash, result.error_index as i64); + hash = flex_wrap_mix(hash, result.lines.len as i64); + let i: i32 = 0; + while (i < result.lines.len) { + let line: FlexLine = result.lines.data[i]; + hash = flex_wrap_mix(hash, line.start as i64); + hash = flex_wrap_mix(hash, line.count as i64); + hash = flex_wrap_mix(hash, line.cross_extent); + hash = flex_wrap_mix(hash, line.cross_offset); + i = i + 1; + } + hash = flex_wrap_mix(hash, result.placements.len as i64); + i = 0; + while (i < result.placements.len) { + let placement: FlexPlacement = result.placements.data[i]; + hash = flex_wrap_mix(hash, placement.id); + hash = flex_wrap_mix(hash, placement.frame.x); + hash = flex_wrap_mix(hash, placement.frame.y); + hash = flex_wrap_mix(hash, placement.frame.width); + hash = flex_wrap_mix(hash, placement.frame.height); + hash = flex_wrap_mix(hash, placement.baseline); + i = i + 1; + } + return hash; +} + +fn flex_wrap_layout(items: ArrayList[FlexItem], raw_constraints: Constraints, raw_wrap_style: FlexWrapStyle) FlexWrapResult { + let constraints: Constraints = normalize_constraints(raw_constraints); + let wrap_style: FlexWrapStyle = raw_wrap_style; + let validation: FlexWrapError = flex_wrap_validate(items, constraints, wrap_style); + if (validation != FlexWrapError.none) { + let index: i32 = 0 - 1; + if (validation == FlexWrapError.invalid_item) { + index = 0; + while (index < items.len && flex_wrap_item_valid(items.data[index]) != 0) { index = index + 1; } + } + return flex_wrap_empty_result(validation, index); + } + if (wrap_style.line_gap < 0) { wrap_style.line_gap = 0; } + if (wrap_style.flex.gap < 0) { wrap_style.flex.gap = 0; } + wrap_style.flex.padding = flex_safe_insets(wrap_style.flex.padding); + + let main_padding: i64 = flex_main_padding(wrap_style.flex.padding, wrap_style.flex.axis); + let cross_padding: i64 = flex_cross_padding(wrap_style.flex.padding, wrap_style.flex.axis); + let main_limit: i64 = flex_main_max(constraints, wrap_style.flex.axis); + let desired_main: i64 = main_limit; + let i: i32 = 0; + if (main_limit >= unbounded_extent()) { + desired_main = main_padding; + while (i < items.len) { + desired_main = flex_wrap_checked_add(desired_main, flex_item_main(items.data[i])); + if (desired_main < 0) { return flex_wrap_empty_result(FlexWrapError.arithmetic_overflow, i); } + if (i > 0) { + desired_main = flex_wrap_checked_add(desired_main, wrap_style.flex.gap); + if (desired_main < 0) { return flex_wrap_empty_result(FlexWrapError.arithmetic_overflow, i); } + } + i = i + 1; + } + } + let container_main: i64 = desired_main; + if (main_limit < unbounded_extent()) { container_main = main_limit; } + container_main = clamp_i64(container_main, flex_main_min(constraints, wrap_style.flex.axis), main_limit); + let inner_main: i64 = container_main - main_padding; + if (inner_main < 0) { inner_main = 0; } + + let lines: ArrayList[FlexLine] = make[FlexLine](16); + let line_start: i32 = 0; + let line_count: i32 = 0; + let line_used: i64 = 0; + i = 0; + while (i < items.len) { + let item_main: i64 = flex_item_main(items.data[i]); + let next_used: i64 = line_used; + if (line_count > 0) { next_used = flex_wrap_checked_add(next_used, wrap_style.flex.gap); } + if (next_used >= 0) { next_used = flex_wrap_checked_add(next_used, item_main); } + if (next_used < 0) { + free[FlexLine](&lines); + return flex_wrap_empty_result(FlexWrapError.arithmetic_overflow, i); + } + if (wrap_style.wrap != 0 && line_count > 0 && next_used > inner_main) { + push[FlexLine](&lines, FlexLine{ + .start = line_start, + .count = line_count, + .cross_extent = flex_line_cross_extent(items, line_start, line_count, wrap_style.flex), + .cross_offset = 0, + }); + line_start = i; + line_count = 0; + line_used = 0; + } + if (line_count > 0) { line_used = flex_wrap_checked_add(line_used, wrap_style.flex.gap); } + if (line_used >= 0) { line_used = flex_wrap_checked_add(line_used, item_main); } + if (line_used < 0) { + free[FlexLine](&lines); + return flex_wrap_empty_result(FlexWrapError.arithmetic_overflow, i); + } + line_count = line_count + 1; + i = i + 1; + } + if (line_count > 0) { + push[FlexLine](&lines, FlexLine{ + .start = line_start, + .count = line_count, + .cross_extent = flex_line_cross_extent(items, line_start, line_count, wrap_style.flex), + .cross_offset = 0, + }); + } + + let lines_cross: i64 = 0; + i = 0; + while (i < lines.len) { + lines_cross = flex_wrap_checked_add(lines_cross, lines.data[i].cross_extent); + if (lines_cross < 0) { + free[FlexLine](&lines); + return flex_wrap_empty_result(FlexWrapError.arithmetic_overflow, i); + } + i = i + 1; + } + let line_gap_total: i64 = 0; + i = 1; + while (i < lines.len) { + line_gap_total = flex_wrap_checked_add(line_gap_total, wrap_style.line_gap); + if (line_gap_total < 0) { + free[FlexLine](&lines); + return flex_wrap_empty_result(FlexWrapError.arithmetic_overflow, i); + } + i = i + 1; + } + let desired_cross: i64 = flex_wrap_checked_add(cross_padding, lines_cross); + if (desired_cross >= 0) { desired_cross = flex_wrap_checked_add(desired_cross, line_gap_total); } + if (desired_cross < 0) { + free[FlexLine](&lines); + return flex_wrap_empty_result(FlexWrapError.arithmetic_overflow, 0 - 1); + } + let cross_limit: i64 = flex_cross_max(constraints, wrap_style.flex.axis); + let container_cross: i64 = clamp_i64(desired_cross, flex_cross_min(constraints, wrap_style.flex.axis), cross_limit); + let available_cross: i64 = container_cross - cross_padding; + let positive_extra: i64 = available_cross - lines_cross - line_gap_total; + if (positive_extra < 0) { positive_extra = 0; } + let leading: i64 = 0; + let extra_line_gap: i64 = 0; + let stretch_remainder: i64 = 0; + switch (wrap_style.line_alignment) { + .start => { } + .center => { leading = positive_extra / 2; } + .end => { leading = positive_extra; } + .space_between => { + if (lines.len > 1) { extra_line_gap = positive_extra / ((lines.len - 1) as i64); } + } + .space_around => { + if (lines.len > 0) { + extra_line_gap = positive_extra / (lines.len as i64); + leading = extra_line_gap / 2; + } + } + .space_evenly => { + if (lines.len > 0) { + extra_line_gap = positive_extra / ((lines.len + 1) as i64); + leading = extra_line_gap; + } + } + .stretch => { + if (lines.len > 0) { stretch_remainder = positive_extra; } + } + } + + let placements: ArrayList[FlexPlacement] = make[FlexPlacement](items.len); + let cross_cursor: i64 = leading; + let overflow_main: i64 = 0; + let stretched: i64 = 0; + i = 0; + while (i < lines.len) { + let line: FlexLine = lines.data[i]; + let line_cross: i64 = line.cross_extent; + if (wrap_style.line_alignment == FlexLineAlignment.stretch && lines.len > 0) { + let share: i64 = 0; + if (i + 1 == lines.len) { share = stretch_remainder - stretched; } + else { share = stretch_remainder / (lines.len as i64); } + line_cross = line_cross + share; + stretched = stretched + share; + } + let line_items: ArrayList[FlexItem] = make[FlexItem](line.count); + let j: i32 = 0; + while (j < line.count) { + push[FlexItem](&line_items, items.data[line.start + j]); + j = j + 1; + } + let line_style: FlexStyle = wrap_style.flex; + line_style.padding = insets_all(0); + let line_constraints: Constraints = tight_constraints(inner_main, line_cross); + if (wrap_style.flex.axis == FlexAxis.column) { + line_constraints = tight_constraints(line_cross, inner_main); + } + let laid_out: FlexResult = flex_layout(line_items, line_constraints, line_style); + if (laid_out.error != FlexError.none) { + let error_index: i32 = laid_out.error_index; + if (error_index >= 0) { error_index = error_index + line.start; } + flex_free(&laid_out); + free[FlexItem](&line_items); + free[FlexPlacement](&placements); + free[FlexLine](&lines); + return flex_wrap_empty_result(FlexWrapError.arithmetic_overflow, error_index); + } + if (laid_out.overflow > overflow_main) { overflow_main = laid_out.overflow; } + + let band_offset: i64 = cross_cursor; + lines.data[i].cross_extent = line_cross; + if (wrap_style.flex.axis == FlexAxis.row) { + lines.data[i].cross_offset = wrap_style.flex.padding.top + band_offset; + } else if (wrap_style.flex.direction == LayoutDirection.ltr) { + lines.data[i].cross_offset = wrap_style.flex.padding.left + band_offset; + } else { + lines.data[i].cross_offset = container_cross - wrap_style.flex.padding.right - band_offset - line_cross; + } + + j = 0; + while (j < laid_out.placements.len) { + let placement: FlexPlacement = laid_out.placements.data[j]; + if (wrap_style.flex.axis == FlexAxis.row) { + placement.frame.x = placement.frame.x + wrap_style.flex.padding.left; + placement.frame.y = placement.frame.y + wrap_style.flex.padding.top + band_offset; + placement.baseline = placement.baseline + wrap_style.flex.padding.top + band_offset; + } else { + placement.frame.y = placement.frame.y + wrap_style.flex.padding.top; + placement.frame.x = placement.frame.x + lines.data[i].cross_offset; + placement.baseline = placement.baseline + wrap_style.flex.padding.top; + } + push[FlexPlacement](&placements, placement); + j = j + 1; + } + flex_free(&laid_out); + free[FlexItem](&line_items); + cross_cursor = cross_cursor + line_cross; + if (i + 1 < lines.len) { cross_cursor = cross_cursor + wrap_style.line_gap + extra_line_gap; } + i = i + 1; + } + + let output_size: Size = switch (wrap_style.flex.axis) { + .row => size(container_main, container_cross), + .column => size(container_cross, container_main), + }; + let overflow_cross: i64 = desired_cross - container_cross; + if (overflow_cross < 0) { overflow_cross = 0; } + let result: FlexWrapResult = FlexWrapResult{ + .placements = placements, + .lines = lines, + .container = output_size, + .overflow_main = overflow_main, + .overflow_cross = overflow_cross, + .duplicate_ids = flex_has_duplicate_id(items), + .wrapped = (lines.len > 1) as i32, + .layout_hash = 0, + .error = FlexWrapError.none, + .error_index = 0 - 1, + }; + result.layout_hash = flex_wrap_hash(result); + return result; +} diff --git a/src/replay/replay.zag b/src/replay/replay.zag index e989207..7a805df 100644 --- a/src/replay/replay.zag +++ b/src/replay/replay.zag @@ -2,6 +2,7 @@ @import("../core/state.zag") @import("../input/router.zag") @import("../layout/flex.zag") +@import("../layout/flex_wrap.zag") @import("../semantics/semantics.zag") @import("../render/cpu_raster.zag") @import("../motion/motion.zag") @@ -68,6 +69,7 @@ struct ReplayResult { motion_status: MotionStatus, semantics_hash: i64, layout_hash: i64, + layout_lines: i32, display_hash: i64, cpu_hash: i64, output_hash: i64, @@ -240,6 +242,7 @@ fn replay_result_error(error: ReplayError, event_index: i32, applied: i64) Repla .motion_status = MotionStatus.settled, .semantics_hash = 0, .layout_hash = 0, + .layout_lines = 0, .display_hash = 0, .cpu_hash = 0, .output_hash = 0, @@ -319,24 +322,6 @@ fn replay_input_hash(router: InputRouter) i64 { return display_mix(hash, router.focus.generation); } -fn replay_layout_hash(layout: FlexResult) i64 { - let hash: i64 = display_mix(display_mix(17, layout.container.width), layout.container.height); - hash = display_mix(hash, layout.overflow); - hash = display_mix(hash, layout.duplicate_ids as i64); - let i: i32 = 0; - while (i < layout.placements.len) { - let placement: FlexPlacement = layout.placements.data[i]; - hash = display_mix(hash, placement.id); - hash = display_mix(hash, placement.frame.x); - hash = display_mix(hash, placement.frame.y); - hash = display_mix(hash, placement.frame.width); - hash = display_mix(hash, placement.frame.height); - hash = display_mix(hash, placement.baseline); - i = i + 1; - } - return hash; -} - fn replay_runtime_free(router: *InputRouter, tree: *HitTree, motion: *MotionScheduler) void { input_router_free(router); hit_tree_free(tree); @@ -367,22 +352,29 @@ fn replay_build_scene(state_value: i64, backend_generation: i64) ReplayResult { let magnitude: i64 = state_value % 3; if (magnitude < 0) { magnitude = 0 - magnitude; } - let items: ArrayList[FlexItem] = make[FlexItem](2); + let items: ArrayList[FlexItem] = make[FlexItem](3); let first: FlexItem = flex_item(101, (2 + magnitude) * unit_scale(), 4 * unit_scale()); first.shrink = 0; push[FlexItem](&items, first); let second: FlexItem = flex_item(102, 2 * unit_scale(), 4 * unit_scale()); second.shrink = 0; push[FlexItem](&items, second); + let third: FlexItem = flex_item(103, 2 * unit_scale(), 2 * unit_scale()); + third.shrink = 0; + push[FlexItem](&items, third); let style: FlexStyle = flex_style(FlexAxis.row, unit_scale(), insets_all(0)); - let layout: FlexResult = flex_layout(items, tight_constraints(8 * unit_scale(), 4 * unit_scale()), style); + let wrap_style: FlexWrapStyle = flex_wrap_style(style, unit_scale()); + let layout: FlexWrapResult = flex_wrap_layout( + items, tight_constraints(8 * unit_scale(), 6 * unit_scale()), wrap_style + ); free[FlexItem](&items); - if (layout.duplicate_ids != 0 || layout.placements.len != 2) { - flex_free(&layout); + if (layout.error != FlexWrapError.none || layout.duplicate_ids != 0 || layout.placements.len != 3) { + flex_wrap_free(&layout); semantics_tree_free(&semantics); return replay_result_error(ReplayError.scene_error, 0 - 1, 0); } - result.layout_hash = replay_layout_hash(layout); + result.layout_hash = layout.layout_hash; + result.layout_lines = layout.lines.len; let list: DisplayList = display_list_make(); let first_op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(101)); @@ -391,27 +383,36 @@ fn replay_build_scene(state_value: i64, backend_generation: i64) ReplayResult { first_op.paint = paint_rgba16(8192 + magnitude * 8192, 24576, 32768 + blue, 65535); if (display_list_push(&list, first_op) != DisplayError.none) { display_list_free(&list); - flex_free(&layout); + flex_wrap_free(&layout); semantics_tree_free(&semantics); return replay_result_error(ReplayError.scene_error, 0 - 1, 0); } let second_op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(102)); second_op.bounds = layout.placements.data[1].frame; second_op.paint = paint_rgba16(49152, 16384 + magnitude * 4096, 12288, 65535); - if (display_list_push(&list, second_op) != DisplayError.none || + if (display_list_push(&list, second_op) != DisplayError.none) { + display_list_free(&list); + flex_wrap_free(&layout); + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } + let third_op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(103)); + third_op.bounds = layout.placements.data[2].frame; + third_op.paint = paint_rgba16(12288, 40960, 24576 + magnitude * 4096, 65535); + if (display_list_push(&list, third_op) != DisplayError.none || display_list_seal(&list) != DisplayError.none) { display_list_free(&list); - flex_free(&layout); + flex_wrap_free(&layout); semantics_tree_free(&semantics); return replay_result_error(ReplayError.scene_error, 0 - 1, 0); } result.display_hash = list.content_hash; - let raster: CpuRasterResult = cpu_rasterize(list, 8, 4); + let raster: CpuRasterResult = cpu_rasterize(list, 8, 6); if (raster.error != CpuRasterError.none) { cpu_raster_free(&raster); display_list_free(&list); - flex_free(&layout); + flex_wrap_free(&layout); semantics_tree_free(&semantics); return replay_result_error(ReplayError.scene_error, 0 - 1, 0); } @@ -419,7 +420,7 @@ fn replay_build_scene(state_value: i64, backend_generation: i64) ReplayResult { cpu_raster_free(&raster); display_list_free(&list); - flex_free(&layout); + flex_wrap_free(&layout); semantics_tree_free(&semantics); return result; } diff --git a/tests/flex_adaptive_contract.zag b/tests/flex_adaptive_contract.zag new file mode 100644 index 0000000..74730c8 --- /dev/null +++ b/tests/flex_adaptive_contract.zag @@ -0,0 +1,320 @@ +@import("../src/layout/flex_wrap.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn report_i64(label: []u8, value: i64) void { + let text: []u8 = _zag_i64_to_str(value); + _zag_print(label); _zag_println(text); _zag_str_free(text); +} + +fn wrap_items(count: i32, main: i64, cross: i64) ArrayList[FlexItem] { + let items: ArrayList[FlexItem] = make[FlexItem](count); + let i: i32 = 0; + while (i < count) { + push[FlexItem](&items, flex_item((i + 1) as i64, main, cross)); + i = i + 1; + } + return items; +} + +fn adaptive_contract(state: *TestState) void { + expect(state, flex_spacing(FlexSpacingToken.small, FlexDensity.standard) == 8 * unit_scale() && + flex_spacing(FlexSpacingToken.small, FlexDensity.compact) == 6 * unit_scale() && + flex_spacing(FlexSpacingToken.small, FlexDensity.touch) == 10 * unit_scale(), + "semantic spacing resolves to exact density-aware units"); + expect(state, flex_spacing(FlexSpacingToken.none, FlexDensity.touch) == 0 && + flex_spacing(FlexSpacingToken.xxlarge, FlexDensity.standard) == 32 * unit_scale(), + "spacing scale keeps explicit zero and largest token stable"); + + let context: FlexAdaptiveContext = flex_adaptive_context(size(800 * unit_scale(), 600 * unit_scale())); + context.density = FlexDensity.touch; + context.direction = LayoutDirection.rtl; + context.safe_area = Insets{ + .top = unit_scale(), .right = 2 * unit_scale(), + .bottom = 3 * unit_scale(), .left = 4 * unit_scale(), + }; + let style: FlexStyle = flex_adaptive_style( + FlexAxis.row, FlexSpacingToken.small, FlexSpacingToken.medium, context, 1 + ); + expect(state, style.gap == 10 * unit_scale() && style.direction == LayoutDirection.rtl && + style.padding.top == 16 * unit_scale() && style.padding.right == 17 * unit_scale() && + style.padding.bottom == 18 * unit_scale() && style.padding.left == 19 * unit_scale(), + "adaptive style composes token padding and physical safe areas exactly"); + + let compact: FlexAdaptiveContext = flex_adaptive_context(size(599 * unit_scale(), 800 * unit_scale())); + let medium: FlexAdaptiveContext = flex_adaptive_context(size(600 * unit_scale(), 800 * unit_scale())); + let expanded: FlexAdaptiveContext = flex_adaptive_context(size(1024 * unit_scale(), 800 * unit_scale())); + expect(state, flex_breakpoint(compact) == FlexBreakpoint.compact && + flex_breakpoint(medium) == FlexBreakpoint.medium && + flex_breakpoint(expanded) == FlexBreakpoint.expanded, + "breakpoint boundaries are exact in density-independent content width"); + medium.text_scale = 2 * unit_scale(); + expect(state, flex_breakpoint(medium) == FlexBreakpoint.compact, + "large text moves the same viewport to a safer compact composition"); + expanded.safe_area.left = 250 * unit_scale(); + expanded.safe_area.right = 250 * unit_scale(); + expect(state, flex_breakpoint(expanded) == FlexBreakpoint.compact && + flex_content_size(expanded).width == 524 * unit_scale(), + "safe areas participate in breakpoint truth rather than clipping afterward"); + expanded.safe_area.left = unbounded_extent(); + expanded.safe_area.right = unbounded_extent(); + expect(state, flex_content_size(expanded).width == 0, + "extreme platform insets clamp before content arithmetic"); + let extreme_style: FlexStyle = flex_adaptive_style( + FlexAxis.row, FlexSpacingToken.small, FlexSpacingToken.medium, expanded, 1 + ); + expect(state, extreme_style.padding.left == unbounded_extent() / 4 && + extreme_style.padding.right == unbounded_extent() / 4, + "token padding cannot push normalized safe areas outside bounded layout geometry"); +} + +fn row_wrap_contract(state: *TestState) void { + let items: ArrayList[FlexItem] = wrap_items(5, 3 * unit_scale(), 2 * unit_scale()); + let base: FlexStyle = flex_style(FlexAxis.row, unit_scale(), insets_all(unit_scale())); + let style: FlexWrapStyle = flex_wrap_style(base, unit_scale()); + let result: FlexWrapResult = flex_wrap_layout( + items, tight_constraints(10 * unit_scale(), 10 * unit_scale()), style + ); + expect(state, result.lines.len == 3 && result.lines.data[0].count == 2 && + result.lines.data[1].count == 2 && result.lines.data[2].count == 1 && result.wrapped == 1, + "row wrapping creates deterministic stable-ID line ranges"); + expect(state, result.placements.data[0].frame.x == unit_scale() && + result.placements.data[1].frame.x == 5 * unit_scale() && + result.placements.data[2].frame.x == unit_scale(), + "item gaps and line reset positions stay exact"); + expect(state, result.placements.data[0].frame.y == unit_scale() && + result.placements.data[2].frame.y == 4 * unit_scale() && + result.placements.data[4].frame.y == 7 * unit_scale(), + "line gaps and outer padding stay exact"); + expect(state, result.overflow_main == 0 && result.overflow_cross == 0 && + result.duplicate_ids == 0 && result.layout_hash != 0, + "wrapped layout reports clean overflow and deterministic identity"); + + let repeated: FlexWrapResult = flex_wrap_layout( + items, tight_constraints(10 * unit_scale(), 10 * unit_scale()), style + ); + expect(state, repeated.layout_hash == result.layout_hash, + "identical wrap inputs produce identical placement identity"); + flex_wrap_free(&repeated); + flex_wrap_free(&result); + free[FlexItem](&items); +} + +fn rtl_and_line_alignment_contract(state: *TestState) void { + let items: ArrayList[FlexItem] = wrap_items(5, 3 * unit_scale(), 2 * unit_scale()); + let base: FlexStyle = flex_style(FlexAxis.row, unit_scale(), insets_all(unit_scale())); + base.direction = LayoutDirection.rtl; + let style: FlexWrapStyle = flex_wrap_style(base, unit_scale()); + style.line_alignment = FlexLineAlignment.center; + let result: FlexWrapResult = flex_wrap_layout( + items, tight_constraints(10 * unit_scale(), 14 * unit_scale()), style + ); + expect(state, result.placements.data[0].frame.x == 6 * unit_scale() && + result.placements.data[1].frame.x == 2 * unit_scale(), + "RTL wrap reverses physical placement while preserving logical item order"); + expect(state, result.lines.data[0].cross_offset == 3 * unit_scale() && + result.placements.data[0].frame.y == 3 * unit_scale(), + "center line alignment distributes cross-axis room exactly"); + flex_wrap_free(&result); + + style.line_alignment = FlexLineAlignment.stretch; + let stretched: FlexWrapResult = flex_wrap_layout( + items, tight_constraints(10 * unit_scale(), 13 * unit_scale()), style + ); + expect(state, stretched.lines.data[0].cross_extent == 3 * unit_scale() && + stretched.lines.data[1].cross_offset == 5 * unit_scale() && + stretched.lines.data[2].cross_offset == 9 * unit_scale(), + "line stretching distributes the exact remainder without changing item size"); + flex_wrap_free(&stretched); + free[FlexItem](&items); +} + +fn column_wrap_contract(state: *TestState) void { + let items: ArrayList[FlexItem] = wrap_items(5, 3 * unit_scale(), 2 * unit_scale()); + let base: FlexStyle = flex_style(FlexAxis.column, unit_scale(), insets_all(unit_scale())); + let style: FlexWrapStyle = flex_wrap_style(base, unit_scale()); + let result: FlexWrapResult = flex_wrap_layout( + items, tight_constraints(10 * unit_scale(), 10 * unit_scale()), style + ); + expect(state, result.lines.len == 3 && result.placements.data[0].frame.x == unit_scale() && + result.placements.data[2].frame.x == 4 * unit_scale() && + result.placements.data[4].frame.x == 7 * unit_scale(), + "column wrapping advances physical columns with exact line gaps"); + expect(state, result.placements.data[0].frame.y == unit_scale() && + result.placements.data[1].frame.y == 5 * unit_scale(), + "column main-axis gaps remain exact"); + flex_wrap_free(&result); + + base.direction = LayoutDirection.rtl; + style.flex = base; + let rtl: FlexWrapResult = flex_wrap_layout( + items, tight_constraints(10 * unit_scale(), 10 * unit_scale()), style + ); + expect(state, rtl.placements.data[0].frame.x == 7 * unit_scale() && + rtl.placements.data[2].frame.x == 4 * unit_scale() && + rtl.placements.data[4].frame.x == unit_scale(), + "RTL column wrapping advances line bands from the physical right"); + flex_wrap_free(&rtl); + free[FlexItem](&items); +} + +fn baseline_and_growth_contract(state: *TestState) void { + let baseline_items: ArrayList[FlexItem] = make[FlexItem](2); + let a: FlexItem = flex_item(1, 3 * unit_scale(), 4 * unit_scale()); a.baseline = unit_scale(); + let b: FlexItem = flex_item(2, 3 * unit_scale(), 4 * unit_scale()); b.baseline = 3 * unit_scale(); + push[FlexItem](&baseline_items, a); push[FlexItem](&baseline_items, b); + let base: FlexStyle = flex_style(FlexAxis.row, unit_scale(), insets_all(unit_scale())); + base.cross_alignment = CrossAlignment.baseline; + let baseline_result: FlexWrapResult = flex_wrap_layout( + baseline_items, tight_constraints(10 * unit_scale(), 8 * unit_scale()), flex_wrap_style(base, 0) + ); + expect(state, baseline_result.lines.data[0].cross_extent == 6 * unit_scale() && + baseline_result.placements.data[0].baseline == baseline_result.placements.data[1].baseline, + "wrapped baseline lines reserve ascent plus descent without clipping"); + flex_wrap_free(&baseline_result); free[FlexItem](&baseline_items); + + let grow_items: ArrayList[FlexItem] = wrap_items(3, 2 * unit_scale(), 2 * unit_scale()); + grow_items.data[0].grow = 1; grow_items.data[1].grow = 1; grow_items.data[2].grow = 1; + let grow_style: FlexWrapStyle = flex_wrap_style( + flex_style(FlexAxis.row, unit_scale(), insets_all(unit_scale())), unit_scale() + ); + let grown: FlexWrapResult = flex_wrap_layout( + grow_items, tight_constraints(8 * unit_scale(), 7 * unit_scale()), grow_style + ); + if (grown.placements.data[0].frame.width != 5 * unit_scale() / 2 || + grown.placements.data[1].frame.width != 5 * unit_scale() / 2 || + grown.placements.data[2].frame.width != 6 * unit_scale()) { + report_i64(" grown line count=", grown.lines.len as i64); + report_i64(" first width=", grown.placements.data[0].frame.width); + report_i64(" second width=", grown.placements.data[1].frame.width); + report_i64(" third width=", grown.placements.data[2].frame.width); + } + expect(state, grown.lines.len == 2 && grown.placements.data[0].frame.width == 5 * unit_scale() / 2 && + grown.placements.data[1].frame.width == 5 * unit_scale() / 2 && + grown.placements.data[2].frame.width == 6 * unit_scale(), + "grow and exact remainder distribution execute independently per wrapped line"); + flex_wrap_free(&grown); free[FlexItem](&grow_items); +} + +fn overflow_and_property_contract(state: *TestState) void { + let items: ArrayList[FlexItem] = wrap_items(3, 5 * unit_scale(), 2 * unit_scale()); + let i: i32 = 0; + while (i < items.len) { items.data[i].min_main = 5 * unit_scale(); i = i + 1; } + let base: FlexStyle = flex_style(FlexAxis.row, unit_scale(), insets_all(unit_scale())); + let wrapped: FlexWrapResult = flex_wrap_layout( + items, tight_constraints(6 * unit_scale(), 10 * unit_scale()), flex_wrap_style(base, unit_scale()) + ); + expect(state, wrapped.lines.len == 3 && wrapped.overflow_main == unit_scale(), + "oversized minimums wrap individually and retain exact main overflow truth"); + flex_wrap_free(&wrapped); + + let nowrap_style: FlexWrapStyle = flex_wrap_style(base, unit_scale()); + nowrap_style.wrap = 0; + let nowrap: FlexWrapResult = flex_wrap_layout( + items, tight_constraints(6 * unit_scale(), 4 * unit_scale()), nowrap_style + ); + expect(state, nowrap.lines.len == 1 && nowrap.overflow_main == 13 * unit_scale() && nowrap.wrapped == 0, + "disabled wrapping preserves one line and reports all constrained overflow"); + flex_wrap_free(&nowrap); + + items.data[1].id = items.data[0].id; + let duplicate: FlexWrapResult = flex_wrap_layout( + items, tight_constraints(6 * unit_scale(), 10 * unit_scale()), flex_wrap_style(base, unit_scale()) + ); + expect(state, duplicate.duplicate_ids == 1, + "duplicate stable IDs remain fail-visible through wrapped layout"); + flex_wrap_free(&duplicate); free[FlexItem](&items); + + let all_valid: i32 = 1; + let width: i64 = 8; + while (width <= 32) { + let sweep: ArrayList[FlexItem] = wrap_items(10, 2 * unit_scale(), unit_scale()); + let result: FlexWrapResult = flex_wrap_layout( + sweep, + Constraints{ + .min_width = width * unit_scale(), .max_width = width * unit_scale(), + .min_height = 0, .max_height = unbounded_extent(), + }, + flex_wrap_style(flex_style(FlexAxis.row, unit_scale(), insets_all(unit_scale())), unit_scale()) + ); + let j: i32 = 0; + while (j < result.placements.len) { + let frame: Rect = result.placements.data[j].frame; + if (result.placements.data[j].id != (j + 1) as i64 || frame.x < unit_scale() || + frame.x + frame.width > width * unit_scale() - unit_scale()) { all_valid = 0; } + j = j + 1; + } + flex_wrap_free(&result); free[FlexItem](&sweep); + width = width + 1; + } + expect(state, all_valid == 1, + "25-width wrap sweep preserves logical IDs and physical content bounds"); +} + +fn malformed_contract(state: *TestState) void { + let items: ArrayList[FlexItem] = wrap_items(2, 2 * unit_scale(), unit_scale()); + items.data[1].id = 0; + let style: FlexWrapStyle = flex_wrap_style( + flex_style(FlexAxis.row, unit_scale(), insets_all(0)), unit_scale() + ); + let malformed: FlexWrapResult = flex_wrap_layout( + items, tight_constraints(10 * unit_scale(), 4 * unit_scale()), style + ); + expect(state, malformed.error == FlexWrapError.invalid_item && malformed.error_index == 1 && + malformed.placements.len == 0 && malformed.lines.len == 0 && malformed.layout_hash != 0, + "malformed item geometry fails at the exact item before placement"); + flex_wrap_free(&malformed); + + items.data[1].id = 2; + let invalid_style: FlexWrapStyle = style; + invalid_style.line_gap = unbounded_extent() / 2; + let bad_style: FlexWrapResult = flex_wrap_layout( + items, tight_constraints(10 * unit_scale(), 4 * unit_scale()), invalid_style + ); + expect(state, bad_style.error == FlexWrapError.invalid_style && bad_style.placements.len == 0, + "unsafe style extents fail before layout arithmetic"); + flex_wrap_free(&bad_style); + + items.data[0].basis = unbounded_extent(); + items.data[1].basis = unbounded_extent(); + let overflow: FlexWrapResult = flex_wrap_layout( + items, + Constraints{ + .min_width = 0, .max_width = unbounded_extent(), + .min_height = 0, .max_height = unbounded_extent(), + }, + flex_wrap_style(flex_style(FlexAxis.row, 0, insets_all(0)), 0) + ); + expect(state, overflow.error == FlexWrapError.arithmetic_overflow && overflow.error_index == 1, + "unbounded intrinsic totals fail visibly instead of wrapping signed arithmetic"); + flex_wrap_free(&overflow); + free[FlexItem](&items); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + adaptive_contract(&state); + row_wrap_contract(&state); + rtl_and_line_alignment_contract(&state); + column_wrap_contract(&state); + baseline_and_growth_contract(&state); + overflow_and_property_contract(&state); + malformed_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Flex adaptive contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tests/flex_contract.zag b/tests/flex_contract.zag index da51d8e..1b99bc7 100644 --- a/tests/flex_contract.zag +++ b/tests/flex_contract.zag @@ -108,6 +108,32 @@ fn safety_contracts(state: *TestState) void { expect(state, result.duplicate_ids == 1, "duplicate stable IDs fail visibly"); expect(state, result.overflow == 20, "min-size overflow is explicit"); flex_free(&result); free[FlexItem](&items); + + let malformed: ArrayList[FlexItem] = make[FlexItem](2); + push[FlexItem](&malformed, item(1, 10, 10)); + push[FlexItem](&malformed, item(0, 10, 10)); + let bad_item: FlexResult = flex_layout( + malformed, tight_constraints(20, 10), flex_style(FlexAxis.row, 0, insets_all(0)) + ); + expect(state, bad_item.error == FlexError.invalid_item && bad_item.error_index == 1 && + bad_item.placements.len == 0, + "malformed single-line items fail at the exact index before placement"); + flex_free(&bad_item); + + malformed.data[1].id = 2; + malformed.data[0].basis = unbounded_extent(); + malformed.data[1].basis = unbounded_extent(); + let arithmetic: FlexResult = flex_layout( + malformed, + Constraints{ + .min_width = 0, .max_width = unbounded_extent(), + .min_height = 0, .max_height = unbounded_extent(), + }, + flex_style(FlexAxis.row, 0, insets_all(0)) + ); + expect(state, arithmetic.error == FlexError.arithmetic_overflow && arithmetic.error_index == 1, + "single-line intrinsic totals fail visibly before signed overflow"); + flex_free(&arithmetic); free[FlexItem](&malformed); } fn property_sweep(state: *TestState) void { diff --git a/tests/replay_contract.zag b/tests/replay_contract.zag index e8b6ed5..5dfee34 100644 --- a/tests/replay_contract.zag +++ b/tests/replay_contract.zag @@ -39,6 +39,7 @@ fn results_equal(a: ReplayResult, b: ReplayResult) i32 { a.motion_value == b.motion_value && a.motion_velocity == b.motion_velocity && a.motion_status == b.motion_status && a.semantics_hash == b.semantics_hash && a.layout_hash == b.layout_hash && + a.layout_lines == b.layout_lines && a.display_hash == b.display_hash && a.cpu_hash == b.cpu_hash && a.output_hash == b.output_hash) as i32; } @@ -62,6 +63,8 @@ fn deterministic_contract(state: *TestState) void { expect(state, first.motion_value > 0 && first.motion_velocity > 0 && first.motion_status == MotionStatus.running, "replayed clock samples advance interruptible motion state"); + expect(state, first.layout_lines == 2, + "replayed state regenerates the expected wrapped Flex line structure"); expect(state, results_equal(first, second) == 1, "two executions of the same sealed tape are bit-identical"); replay_tape_free(&tape); @@ -154,6 +157,8 @@ fn differential_contract(state: *TestState) void { expect(state, a.output_hash != b.output_hash && a.motion_hash != b.motion_hash && a.semantics_hash != b.semantics_hash && a.layout_hash != b.layout_hash && a.display_hash != b.display_hash && a.cpu_hash != b.cpu_hash, "state changes propagate through motion semantics Flex display and CPU output"); + expect(state, a.layout_lines != b.layout_lines, + "state replay crosses Flex wrapping boundaries deterministically"); expect(state, a.input_hash != c.input_hash && a.motion_hash == c.motion_hash && a.semantics_hash == c.semantics_hash && a.layout_hash == c.layout_hash && a.display_hash == c.display_hash && a.cpu_hash == c.cpu_hash && a.output_hash != c.output_hash, diff --git a/tools/test-headless.sh b/tools/test-headless.sh index a74d0ac..680c808 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -16,6 +16,8 @@ trap cleanup EXIT cd "$root" "$znc" tests/flex_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/flex-contract" "$tmp/flex-contract" +"$znc" tests/flex_adaptive_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/flex-adaptive-contract" +"$tmp/flex-adaptive-contract" "$znc" tests/state_reconcile_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/state-reconcile-contract" "$tmp/state-reconcile-contract" "$znc" tests/semantics_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/semantics-contract" From fabde29a9abf3a837f9bed320be9f5e4955fae43 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 07:27:11 -0700 Subject: [PATCH 017/103] Implement retained intrinsic measurement --- CHANGELOG.md | 7 +- README.md | 34 +- ROADMAP.md | 9 +- docs/architecture/README.md | 17 +- docs/architecture/flex.md | 16 +- docs/architecture/measurement.md | 115 ++++++ src/layout/flex.zag | 31 ++ src/layout/invalidation.zag | 115 ++++++ src/layout/measure.zag | 653 +++++++++++++++++++++++++++++++ src/replay/replay.zag | 54 ++- tests/measure_contract.zag | 420 ++++++++++++++++++++ tests/replay_contract.zag | 16 +- tools/test-headless.sh | 4 +- 13 files changed, 1456 insertions(+), 35 deletions(-) create mode 100644 docs/architecture/measurement.md create mode 100644 src/layout/invalidation.zag create mode 100644 src/layout/measure.zag create mode 100644 tests/measure_contract.zag diff --git a/CHANGELOG.md b/CHANGELOG.md index 64983e3..5ca4f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ include a generated capability record and exact Zag compiler revision. - Experimental keyed reconciliation with stable reorder identity and fail-visible duplicate-key handling. - Deterministic fixed-point geometry, constraints, and single-line Flex. +- Experimental retained intrinsic measurement for leaf, row, column, and + overlay trees with explicit constraint rules, priorities, baselines, + overflow, revision stability, ownership failures, deterministic identity, + and exact state-read layout invalidation causes. - Experimental adaptive Flex primitives with tokenized density-aware spacing, physical safe areas, text-scale-aware breakpoints, deterministic row and column wrapping, RTL line bands, baseline-safe extents, per-line growth, @@ -37,7 +41,8 @@ include a generated capability record and exact Zag compiler revision. z-order, capture, focus, stale-target recovery, and ordered evidence. - A sealed deterministic replay tape and conformance executor covering exact state revisions, pointer input, monotonic time, backend activation, loss, - recovery, and regenerated motion, semantics, Flex, display-list, and CPU identities. + recovery, and regenerated motion, semantics, measurement, Flex, display-list, + and CPU identities. - A refresh-aware deterministic motion scheduler with fixed-step integer springs, owned keyframes, explicit frame reasons, interruption, reversal, resize continuity, gesture velocity handoff, and semantic reduced-motion diff --git a/README.md b/README.md index b2c8ae9..51fb73a 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ modern materials and asset fidelity, and a complete PrismStudio UI replacement. This repository is at **0.1.0-experimental.0**. It currently contains the accepted product contract, executable Milestone 0 checks, and the first -deterministic state, keyed reconciliation, geometry, Flex, semantics, Talkback, -display-list, CPU-oracle, input, replay, and motion slices. It does not yet +deterministic state, keyed reconciliation, geometry, intrinsic measurement, +Flex, semantics, Talkback, display-list, CPU-oracle, input, replay, and motion +slices. It does not yet contain a usable renderer, window shell, component library, or supported platform backend. Nothing in this repository is a Zagkit 1.0 release. @@ -41,7 +42,7 @@ is normative in [DEPENDENCIES.md](DEPENDENCIES.md). | Product and architecture contract | accepted | [RFC index](docs/rfcs/README.md) | | Compiler dependency | pinned, prerequisites incomplete | [toolchain lock](contracts/toolchain.json) | | Platform shells | unavailable | [support matrix](SUPPORT.md) | -| Headless core | experimental state, reconciliation, geometry, Flex, semantics, Talkback, display lists, CPU raster, input, replay, and motion | [headless test](tools/test-headless.sh) | +| Headless core | experimental state, reconciliation, intrinsic measurement, geometry, Flex, semantics, Talkback, display lists, CPU raster, input, replay, and motion | [headless test](tools/test-headless.sh) | | Components and visual language | inventory only, visual review pending | [component inventory](contracts/components.json) | | Flex and Zagkit Talkback | Flex foundation and in-process ID-first Talkback dispatch executing; native transport remains unavailable | [Talkback contract](docs/automation/talkback.md) | | Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | @@ -60,10 +61,14 @@ The compiled headless contract currently provides revisioned `State`, action-producing `Binding`, inherited integer environment values, exact per-node state-read records, fail-visible keyed reconciliation, deterministic fixed-point geometry, and single-line or wrapped Flex. These APIs are -experimental. Typed -environment values, reconciliation cancellation, child ownership, replay -serialization, intrinsic measurement, grid, overlay, and breakpoint policies -remain open; the corresponding Milestone 2 checklist items are not complete. +experimental. A retained intrinsic tree now aggregates leaf, row, column, and +overlay size ranges, rejects invalid ownership and unstable same-revision +measurement, resolves constraints with explicit rules and overflow, and records +exact state-read layout causes. Typed environment values, reconciliation +cancellation, retained child lifecycle, replay serialization, grid and overlay +placement, and full breakpoint policy remain open; the corresponding Milestone +2 checklist items are not complete. See the +[measurement contract](docs/architecture/measurement.md). Flex now also provides an experimental primitive spacing scale, compact, standard, and touch density resolution, physical safe-area composition, @@ -72,8 +77,8 @@ column wrapping. Wrapped lines preserve logical stable-ID order while resolving physical RTL placement, per-line growth and shrinkage, baseline-safe extents, line alignment, overflow truth, and layout identity. These are headless placement primitives rather than the still-unselected visual design language. -Grid, overlay, intrinsic measurement, exact invalidation rules, and the full -adaptive matrix remain open, so `G2-FLEX` and `G2-FLEX-RTL` are not complete. +Grid and overlay placement, end-to-end retained invalidation coverage, and the +full adaptive matrix remain open, so `G2-FLEX` and `G2-FLEX-RTL` are not complete. See the [Flex contract](docs/architecture/flex.md). The semantics slice owns copied names and values, stable keys, explicit action @@ -116,11 +121,12 @@ gestures, and platform input adaptation remain open. The first replay slice owns a sealed ordered tape for exact state revisions, pointer phases, monotonic time, backend activation, device loss, and recovery. -Executing the same tape regenerates identical motion, semantic, Flex, -display-list, and CPU identities; raw mutation, stale revisions, clock -regression, and invalid backend transitions fail before they can become evidence. The current scene is -an executable conformance reference, not yet a general application callback -boundary or a versioned replay-file format, so `G2-REPLAY` remains open. +Executing the same tape regenerates identical motion, semantic, measurement, +Flex, display-list, and CPU identities; raw mutation, stale revisions, clock +regression, and invalid backend transitions fail before they can become +evidence. The current scene is an executable conformance reference, not yet a +general application callback boundary or a versioned replay-file format, so +`G2-REPLAY` remains open. The experimental motion kernel uses supplied monotonic microseconds and the same 26.6 logical units as layout. Its refresh-aware scheduler records why diff --git a/ROADMAP.md b/ROADMAP.md index 5971c91..ae19268 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,7 +36,14 @@ affected Zagkit conformance. ## 2. Headless core -**Status: not started.** +**Status: experimental foundation in progress.** + +Strict native Zag contracts currently execute state and dependency tracking, +keyed reconciliation, bounded geometry, retained intrinsic measurement, +adaptive and wrapped Flex, semantics, ID-first in-process Talkback dispatch, +immutable display lists and their codec, a rectangle CPU-oracle subset, +transformed pointer routing, deterministic replay, and motion kernels. This is +real headless execution but does not satisfy the milestone exit gate yet. Implement state dependencies, reconciliation, layout, semantics, immutable display lists, deterministic CPU rasterization, Unicode and OpenType text, diff --git a/docs/architecture/README.md b/docs/architecture/README.md index f4fd38f..74b6870 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -28,6 +28,7 @@ Normative decisions: Detailed experimental contracts: +- [intrinsic measurement and invalidation reasons](measurement.md) - [Flex placement and adaptive spacing](flex.md) - [motion scheduler and tracks](motion.md) @@ -40,6 +41,14 @@ keys. Ownership, threading, serialization, typed environment values, cancellation and replay serialization are still open contracts rather than stable API. +The retained intrinsic measurement tree aggregates leaf, row, column, and +overlay size ranges with exact gaps, padding, baselines, ownership, constraint +rules, overflow, and deterministic input/output identity. Revision witnesses +reject unstable intrinsic results, while `LayoutTrace` maps real state reads to +the exact node, ancestor, phase, and rule that must recompute. The full contract +and incomplete lifecycle boundaries are documented in +[measurement.md](measurement.md). + Flex extends fixed-point constraints with deterministic single-line and wrapped row or column placement. Its primitive spacing scale adapts to density, safe areas, text scale, breakpoints, and physical RTL without changing logical ID @@ -81,10 +90,10 @@ The first deterministic replay executor consumes an immutable ordered tape of state revisions, pointer events, monotonic clock samples, backend activation, loss, and recovery. It rejects stale or impossible transitions at their exact event index, then rebuilds a conformance scene through the real semantics, -motion, Flex, display-list, and CPU-oracle paths. A repeated tape must produce -identical subsystem and aggregate hashes. A general application callback contract, -versioned tape codec, resource capture, and platform lifecycle integration are -still open. +motion, intrinsic measurement, Flex, display-list, and CPU-oracle paths. A +repeated tape must produce identical subsystem and aggregate hashes. A general +application callback contract, versioned tape codec, resource capture, and +platform lifecycle integration are still open. The motion scheduler consumes an authoritative monotonic clock and advertises the current refresh interval without deriving motion from callback count. diff --git a/docs/architecture/flex.md b/docs/architecture/flex.md index dfe3c82..9979c82 100644 --- a/docs/architecture/flex.md +++ b/docs/architecture/flex.md @@ -46,6 +46,18 @@ Text scale is bounded from 1x through 4x for breakpoint calculation. Larger text therefore selects a roomier composition without distorting the primitive spacing scale. +## Intrinsic items and priorities + +`flex_item_intrinsic` retains a measured node's main-axis minimum, preferred, +and maximum bounds plus preferred cross extent and baseline instead of +flattening it to an unexplained size. +`FlexPriority` provides lowest, low, normal, high, and required policies with +explicit grow and shrink weights. Lower priorities yield more under pressure; +required items neither grow nor shrink. Remaining pressure after intrinsic +bounds is reported as overflow. See the +[measurement contract](measurement.md) for the exact mapping and retained +intrinsic tree. + ## Wrapping `FlexWrapStyle` adds line gap, line alignment, and a wrapping switch to a @@ -87,10 +99,10 @@ the placement and line arrays. The following remain required before either Flex checklist item can complete: -- intrinsic measurement and priorities; - grid and overlay placement; - safe-area and density matrices driven by real platform shells; -- exact state-read and rule reasons for every layout invalidation; +- end-to-end state, environment, and platform-input invalidation reasons across + retained components; - layout-transition orchestration; - full RTL, large-text, and adaptive component conformance; - visual-direction acceptance and screenshot goldens. diff --git a/docs/architecture/measurement.md b/docs/architecture/measurement.md new file mode 100644 index 0000000..3a44371 --- /dev/null +++ b/docs/architecture/measurement.md @@ -0,0 +1,115 @@ +# Experimental intrinsic measurement contract + +Zagkit measurement is retained, deterministic, and independent of a window +system or rendering backend. The current contract operates in signed 26.6 +logical units and gives layout caches an inspectable identity for both inputs +and outputs. It advances `G2-CONSTRAINTS`; it does not complete that checklist +item by itself. + +## Retained tree + +`IntrinsicTree` owns stable `IntrinsicNode` records and parent-child edges. +Leaf nodes declare minimum, preferred, and maximum width and height, a baseline, +and a content revision. Container nodes aggregate those declarations as a row, +column, or overlay with explicit gap and physical padding. + +Rows sum widths, take the largest height, and reserve the largest ascent plus +largest descent. Columns take the largest width and sum heights. Overlays take +the largest extent on both axes. Rows and columns include gaps only between +children. Every form composes padding after child aggregation. + +A valid retained measurement tree has: + +- one positive stable ID per node; +- exactly one owner for every non-root node; +- no orphan, missing, duplicate, or leaf-owned edges; +- monotonic minimum, preferred, and maximum ranges; +- a baseline inside preferred height; +- bounded gap, padding, revision, and fixed-point geometry; +- no ownership cycle. + +The current executable bound is 4,096 nodes, 16,384 ownership edges, and 512 +retained levels per measured tree. Inputs beyond those limits fail before +unbounded allocation or native call-stack growth. + +Malformed trees fail before returning usable size. `MeasureResult` retains the +exact error node and edge where applicable, the count of nodes reached before +failure, and no partial success claim. + +## Constraint resolution + +The intrinsic preferred size is resolved independently on each axis against +normalized `Constraints`. `MeasureRule` records whether intrinsic preference, +the minimum constraint, or the maximum constraint selected the final extent. +If the authoritative constraint is smaller than intrinsic minimum, width and +height overflow remain separate and explicit. Baselines clamp to the resolved +height instead of escaping the result bounds. + +Measurement identity includes the full retained tree, node revisions, +ownership edges, intrinsic result, normalized constraints, resolved size, +baseline, overflow, rules, and visited-node count. A constraint or hidden +overlay-child change therefore cannot masquerade as a cache hit merely because +the final rectangle happens to stay the same. + +## Stability and revisions + +`IntrinsicStability` retains the input hash observed for each stable node and +content revision. A node that returns different intrinsic inputs without +advancing its revision fails as `unstable_measurement`. A changed input is +accepted only after an explicit newer revision. Regressing a revision also +fails. This turns intrinsic instability into inspectable evidence rather than +layout jitter. + +Call `intrinsic_stability_free` and `intrinsic_tree_free` for every owned +instance. + +## Flex priorities + +`flex_item_intrinsic` converts intrinsic metrics into a Flex item while +retaining main-axis minimum, preferred, and maximum bounds plus preferred cross +extent and baseline truth. +`FlexPriority` maps to explicit grow and shrink resistance: + +| Priority | Grow weight | Shrink weight | +|---|---:|---:| +| `lowest` | 0 | 8 | +| `low` | 1 | 4 | +| `normal` | 2 | 2 | +| `high` | 4 | 1 | +| `required` | 0 | 0 | + +Required content neither grows nor yields under pressure. Lower-priority +content yields more of a deficit than higher-priority content. Intrinsic bounds +remain authoritative during distribution, and unresolved pressure is reported +as Flex overflow. + +## Exact invalidation reasons + +The separate `invalidation.zag` module keeps authoring-state dependencies out +of low-level intrinsic and Flex code. Its `LayoutTrace` copies actual +`ViewContext` state reads into retained layout dependencies. Every record names +the reader `NodeKey`, retained ancestor, +state ID and read revision, affected phase, and rule. Current phases are +measure, layout, paint, and semantics. Current rules cover intrinsic content, +constraints, child measurement, Flex priority, gap, padding, direction, safe +area, density, text scale, and breakpoint selection. + +One `StateChange` produces every matching `LayoutInvalidationCause`, including +the exact old and new revision edge. Unread state produces no cause. Repeated +identical reads collapse without losing phase or rule truth. + +## Current boundary + +The following remain before `G2-CONSTRAINTS` can complete: + +- retained `RenderNode` lifecycle and cancellation integration; +- incremental subtree cache replacement and cleanup under reconciliation; +- component and text measurement producers; +- cross-axis intrinsic bounds in Flex item distribution; +- exact non-state environment and platform-input revision dependencies; +- serialized measurement and invalidation evidence; +- property and fuzz coverage across the complete component layout matrix. + +Grid and overlay *placement*, scroll and virtualization, and platform-driven +adaptive conformance remain separate Flex and virtualization work. Overlay +intrinsic aggregation here does not claim overlay placement is implemented. diff --git a/src/layout/flex.zag b/src/layout/flex.zag index d389ce8..dcf4bf1 100644 --- a/src/layout/flex.zag +++ b/src/layout/flex.zag @@ -1,11 +1,13 @@ @import("std:list") @import("../core/geometry.zag") +@import("measure.zag") enum FlexAxis { row, column } enum LayoutDirection { ltr, rtl } enum MainAlignment { start, center, end, space_between, space_around, space_evenly } enum CrossAlignment { start, center, end, stretch, baseline } enum FlexError { none, invalid_constraints, invalid_style, invalid_item, arithmetic_overflow } +enum FlexPriority { lowest, low, normal, high, required } struct FlexItem { id: i64, @@ -55,6 +57,35 @@ fn flex_item(id: i64, basis: i64, cross: i64) FlexItem { }; } +fn flex_item_intrinsic( + id: i64, + metrics: IntrinsicMetrics, + axis: FlexAxis, + priority: FlexPriority, +) FlexItem { + let item: FlexItem = switch (axis) { + .row => flex_item(id, metrics.preferred.width, metrics.preferred.height), + .column => flex_item(id, metrics.preferred.height, metrics.preferred.width), + }; + if (axis == FlexAxis.row) { + item.min_main = metrics.minimum.width; + item.max_main = metrics.maximum.width; + item.baseline = metrics.baseline; + } else { + item.min_main = metrics.minimum.height; + item.max_main = metrics.maximum.height; + item.baseline = metrics.preferred.width; + } + switch (priority) { + .lowest => { item.grow = 0; item.shrink = 8; } + .low => { item.grow = 1; item.shrink = 4; } + .normal => { item.grow = 2; item.shrink = 2; } + .high => { item.grow = 4; item.shrink = 1; } + .required => { item.grow = 0; item.shrink = 0; } + } + return item; +} + fn flex_style(axis: FlexAxis, gap: i64, padding: Insets) FlexStyle { let actual_gap: i64 = gap; if (actual_gap < 0) { actual_gap = 0; } diff --git a/src/layout/invalidation.zag b/src/layout/invalidation.zag new file mode 100644 index 0000000..c484eb7 --- /dev/null +++ b/src/layout/invalidation.zag @@ -0,0 +1,115 @@ +@import("std:list") +@import("../core/view_contract.zag") + +enum LayoutPhase { measure, layout, paint, semantics } +enum LayoutReason { + intrinsic_content, + constraints, + child_measurement, + flex_priority, + gap, + padding, + direction, + safe_area, + density, + text_scale, + breakpoint, +} + +struct LayoutDependency { + node: NodeKey, + ancestor: NodeKey, + state_id: i64, + read_revision: i64, + phase: LayoutPhase, + reason: LayoutReason, +} + +struct LayoutInvalidationCause { + node: NodeKey, + ancestor: NodeKey, + state_id: i64, + read_revision: i64, + from_revision: i64, + to_revision: i64, + phase: LayoutPhase, + reason: LayoutReason, +} + +struct LayoutTrace { + dependencies: ArrayList[LayoutDependency], +} + +struct LayoutInvalidationResult { + causes: ArrayList[LayoutInvalidationCause], +} + +fn layout_trace_make() LayoutTrace { + return LayoutTrace{ .dependencies = make[LayoutDependency](16) }; +} + +fn layout_trace_free(trace: *LayoutTrace) void { + free[LayoutDependency](&trace.*.dependencies); +} + +fn layout_trace_record( + trace: *LayoutTrace, + context: ViewContext, + ancestor: NodeKey, + phase: LayoutPhase, + reason: LayoutReason, +) void { + let i: i32 = 0; + while (i < context.reads.len) { + let read: StateRead = context.reads.data[i]; + let existing: i32 = 0; + while (existing < trace.*.dependencies.len && !( + node_key_equal(trace.*.dependencies.data[existing].node, read.reader) == 1 && + node_key_equal(trace.*.dependencies.data[existing].ancestor, ancestor) == 1 && + trace.*.dependencies.data[existing].state_id == read.state_id && + trace.*.dependencies.data[existing].phase == phase && + trace.*.dependencies.data[existing].reason == reason + )) { existing = existing + 1; } + if (existing < trace.*.dependencies.len) { + trace.*.dependencies.data[existing].read_revision = read.revision; + } else { + push[LayoutDependency](&trace.*.dependencies, LayoutDependency{ + .node = read.reader, + .ancestor = ancestor, + .state_id = read.state_id, + .read_revision = read.revision, + .phase = phase, + .reason = reason, + }); + } + i = i + 1; + } +} + +fn layout_trace_invalidate(trace: LayoutTrace, change: StateChange) LayoutInvalidationResult { + let result: LayoutInvalidationResult = LayoutInvalidationResult{ + .causes = make[LayoutInvalidationCause](4), + }; + let i: i32 = 0; + while (i < trace.dependencies.len) { + let dependency: LayoutDependency = trace.dependencies.data[i]; + if (dependency.state_id == change.state_id && dependency.read_revision < change.to_revision) { + push[LayoutInvalidationCause](&result.causes, LayoutInvalidationCause{ + .node = dependency.node, + .ancestor = dependency.ancestor, + .state_id = dependency.state_id, + .read_revision = dependency.read_revision, + .from_revision = change.from_revision, + .to_revision = change.to_revision, + .phase = dependency.phase, + .reason = dependency.reason, + }); + } + i = i + 1; + } + return result; +} + +fn layout_invalidation_free(result: *LayoutInvalidationResult) void { + free[LayoutInvalidationCause](&result.*.causes); +} diff --git a/src/layout/measure.zag b/src/layout/measure.zag new file mode 100644 index 0000000..53a9aca --- /dev/null +++ b/src/layout/measure.zag @@ -0,0 +1,653 @@ +@import("std:list") +@import("../core/geometry.zag") + +enum IntrinsicKind { leaf, row, column, overlay } +enum IntrinsicError { + none, + too_many_nodes, + invalid_node, + duplicate_node, + missing_node, + missing_child, + invalid_edge, + multiple_parents, + orphan_node, + cycle, + depth_exceeded, + unstable_measurement, + invalid_constraints, + arithmetic_overflow, +} +enum MeasureRule { intrinsic_preferred, minimum_constraint, maximum_constraint } + +struct IntrinsicMetrics { + minimum: Size, + preferred: Size, + maximum: Size, + baseline: i64, +} + +struct IntrinsicNode { + id: i64, + kind: IntrinsicKind, + metrics: IntrinsicMetrics, + gap: i64, + padding: Insets, + revision: i64, +} + +struct IntrinsicEdge { + parent_id: i64, + child_id: i64, +} + +struct IntrinsicTree { + nodes: ArrayList[IntrinsicNode], + edges: ArrayList[IntrinsicEdge], +} + +struct MeasureResult { + intrinsic: IntrinsicMetrics, + resolved: Size, + baseline: i64, + overflow_width: i64, + overflow_height: i64, + width_rule: MeasureRule, + height_rule: MeasureRule, + visited_nodes: i32, + measure_hash: i64, + error: IntrinsicError, + error_node: i64, + error_edge: i32, +} + +struct IntrinsicSolveState { + states: ArrayList[i32], + values: ArrayList[IntrinsicMetrics], + visited_nodes: i32, + error: IntrinsicError, + error_node: i64, + error_edge: i32, + depth: i32, +} + +struct IntrinsicWitness { + node_id: i64, + revision: i64, + input_hash: i64, +} + +struct IntrinsicStability { + witnesses: ArrayList[IntrinsicWitness], +} + +struct IntrinsicStabilityResult { + error: IntrinsicError, + node_id: i64, + previous_hash: i64, + current_hash: i64, +} + +fn intrinsic_metrics( + min_width: i64, + min_height: i64, + preferred_width: i64, + preferred_height: i64, + max_width: i64, + max_height: i64, + baseline: i64, +) IntrinsicMetrics { + return IntrinsicMetrics{ + .minimum = size(min_width, min_height), + .preferred = size(preferred_width, preferred_height), + .maximum = size(max_width, max_height), + .baseline = baseline, + }; +} + +fn intrinsic_zero() IntrinsicMetrics { + return intrinsic_metrics(0, 0, 0, 0, 0, 0, 0); +} + +fn intrinsic_leaf(id: i64, metrics: IntrinsicMetrics, revision: i64) IntrinsicNode { + return IntrinsicNode{ + .id = id, + .kind = IntrinsicKind.leaf, + .metrics = metrics, + .gap = 0, + .padding = insets_all(0), + .revision = revision, + }; +} + +fn intrinsic_group(id: i64, kind: IntrinsicKind, gap: i64, padding: Insets) IntrinsicNode { + return IntrinsicNode{ + .id = id, + .kind = kind, + .metrics = intrinsic_zero(), + .gap = gap, + .padding = padding, + .revision = 0, + }; +} + +fn intrinsic_tree_make() IntrinsicTree { + return IntrinsicTree{ + .nodes = make[IntrinsicNode](16), + .edges = make[IntrinsicEdge](24), + }; +} + +fn intrinsic_node_limit() i32 { return 4096; } +fn intrinsic_edge_limit() i32 { return 16384; } +fn intrinsic_depth_limit() i32 { return 512; } + +fn intrinsic_tree_free(tree: *IntrinsicTree) void { + free[IntrinsicNode](&tree.*.nodes); + free[IntrinsicEdge](&tree.*.edges); +} + +fn intrinsic_tree_add_node(tree: *IntrinsicTree, node: IntrinsicNode) void { + push[IntrinsicNode](&tree.*.nodes, node); +} + +fn intrinsic_tree_add_child(tree: *IntrinsicTree, parent_id: i64, child_id: i64) void { + push[IntrinsicEdge](&tree.*.edges, IntrinsicEdge{ .parent_id = parent_id, .child_id = child_id }); +} + +fn intrinsic_metric_valid(value: IntrinsicMetrics) i32 { + return (value.minimum.width >= 0 && value.minimum.height >= 0 && + value.preferred.width >= value.minimum.width && + value.preferred.height >= value.minimum.height && + value.maximum.width >= value.preferred.width && + value.maximum.height >= value.preferred.height && + value.maximum.width <= unbounded_extent() && + value.maximum.height <= unbounded_extent() && + value.baseline >= 0 && value.baseline <= value.preferred.height) as i32; +} + +fn intrinsic_node_valid(node: IntrinsicNode) i32 { + if (node.id <= 0 || node.revision < 0 || node.gap < 0 || + node.gap > unbounded_extent() / 4 || + node.padding.top < 0 || node.padding.top > unbounded_extent() / 4 || + node.padding.right < 0 || node.padding.right > unbounded_extent() / 4 || + node.padding.bottom < 0 || node.padding.bottom > unbounded_extent() / 4 || + node.padding.left < 0 || node.padding.left > unbounded_extent() / 4) { return 0; } + if (node.kind == IntrinsicKind.leaf) { return intrinsic_metric_valid(node.metrics); } + return 1; +} + +fn intrinsic_find_node(tree: IntrinsicTree, id: i64) i32 { + let i: i32 = 0; + while (i < tree.nodes.len) { + if (tree.nodes.data[i].id == id) { return i; } + i = i + 1; + } + return 0 - 1; +} + +fn intrinsic_add(a: i64, b: i64) i64 { + if (a < 0 || b < 0) { return 0 - 1; } + if (a > unbounded_extent() - b) { return 0 - 1; } + return a + b; +} + +fn intrinsic_add_maximum(a: i64, b: i64) i64 { + if (a < 0 || b < 0) { return 0 - 1; } + if (a == unbounded_extent() || b == unbounded_extent()) { return unbounded_extent(); } + return intrinsic_add(a, b); +} + +fn intrinsic_max(a: i64, b: i64) i64 { + if (b > a) { return b; } + return a; +} + +fn intrinsic_mix(hash: i64, value: i64) i64 { + return (hash * 1099511628211) ^ value; +} + +fn intrinsic_metrics_hash(value: IntrinsicMetrics) i64 { + let hash: i64 = 17; + hash = intrinsic_mix(hash, value.minimum.width); + hash = intrinsic_mix(hash, value.minimum.height); + hash = intrinsic_mix(hash, value.preferred.width); + hash = intrinsic_mix(hash, value.preferred.height); + hash = intrinsic_mix(hash, value.maximum.width); + hash = intrinsic_mix(hash, value.maximum.height); + hash = intrinsic_mix(hash, value.baseline); + return hash; +} + +fn intrinsic_node_hash(node: IntrinsicNode) i64 { + let hash: i64 = intrinsic_mix(17, node.id); + hash = intrinsic_mix(hash, node.kind as i64); + hash = intrinsic_mix(hash, intrinsic_metrics_hash(node.metrics)); + hash = intrinsic_mix(hash, node.gap); + hash = intrinsic_mix(hash, node.padding.top); + hash = intrinsic_mix(hash, node.padding.right); + hash = intrinsic_mix(hash, node.padding.bottom); + hash = intrinsic_mix(hash, node.padding.left); + return hash; +} + +fn intrinsic_tree_hash(tree: IntrinsicTree) i64 { + let hash: i64 = intrinsic_mix(17, tree.nodes.len as i64); + let i: i32 = 0; + while (i < tree.nodes.len) { + let node: IntrinsicNode = tree.nodes.data[i]; + hash = intrinsic_mix(hash, intrinsic_node_hash(node)); + hash = intrinsic_mix(hash, node.revision); + i = i + 1; + } + hash = intrinsic_mix(hash, tree.edges.len as i64); + i = 0; + while (i < tree.edges.len) { + hash = intrinsic_mix(hash, tree.edges.data[i].parent_id); + hash = intrinsic_mix(hash, tree.edges.data[i].child_id); + i = i + 1; + } + return hash; +} + +fn intrinsic_stability_make() IntrinsicStability { + return IntrinsicStability{ .witnesses = make[IntrinsicWitness](16) }; +} + +fn intrinsic_stability_free(stability: *IntrinsicStability) void { + free[IntrinsicWitness](&stability.*.witnesses); +} + +fn intrinsic_stability_observe( + stability: *IntrinsicStability, + tree: IntrinsicTree, +) IntrinsicStabilityResult { + let i: i32 = 0; + while (i < tree.nodes.len) { + let node: IntrinsicNode = tree.nodes.data[i]; + let duplicate_index: i32 = 0; + while (duplicate_index < i) { + if (tree.nodes.data[duplicate_index].id == node.id) { + return IntrinsicStabilityResult{ + .error = IntrinsicError.duplicate_node, + .node_id = node.id, + .previous_hash = 0, + .current_hash = intrinsic_node_hash(node), + }; + } + duplicate_index = duplicate_index + 1; + } + let input_hash: i64 = intrinsic_node_hash(node); + let found: i32 = 0 - 1; + let j: i32 = 0; + while (j < stability.*.witnesses.len) { + if (stability.*.witnesses.data[j].node_id == node.id) { + found = j; + j = stability.*.witnesses.len; + } else { + j = j + 1; + } + } + if (found >= 0) { + let witness: IntrinsicWitness = stability.*.witnesses.data[found]; + if (node.revision == witness.revision && input_hash != witness.input_hash) { + return IntrinsicStabilityResult{ + .error = IntrinsicError.unstable_measurement, + .node_id = node.id, + .previous_hash = witness.input_hash, + .current_hash = input_hash, + }; + } + if (node.revision < witness.revision) { + return IntrinsicStabilityResult{ + .error = IntrinsicError.invalid_node, + .node_id = node.id, + .previous_hash = witness.input_hash, + .current_hash = input_hash, + }; + } + if (node.revision > witness.revision) { + stability.*.witnesses.data[found].revision = node.revision; + stability.*.witnesses.data[found].input_hash = input_hash; + } + } else { + push[IntrinsicWitness](&stability.*.witnesses, IntrinsicWitness{ + .node_id = node.id, + .revision = node.revision, + .input_hash = input_hash, + }); + } + i = i + 1; + } + return IntrinsicStabilityResult{ + .error = IntrinsicError.none, + .node_id = 0, + .previous_hash = 0, + .current_hash = 0, + }; +} + +fn intrinsic_set_error( + solve: *IntrinsicSolveState, + error: IntrinsicError, + node: i64, + edge: i32, +) void { + if (solve.*.error == IntrinsicError.none) { + solve.*.error = error; + solve.*.error_node = node; + solve.*.error_edge = edge; + } +} + +fn intrinsic_compute( + tree: IntrinsicTree, + node_index: i32, + solve: *IntrinsicSolveState, +) IntrinsicMetrics { + if (solve.*.states.data[node_index] == 2) { return solve.*.values.data[node_index]; } + let node: IntrinsicNode = tree.nodes.data[node_index]; + if (solve.*.states.data[node_index] == 1) { + intrinsic_set_error(solve, IntrinsicError.cycle, node.id, 0 - 1); + return intrinsic_zero(); + } + if (solve.*.depth >= intrinsic_depth_limit()) { + intrinsic_set_error(solve, IntrinsicError.depth_exceeded, node.id, 0 - 1); + return intrinsic_zero(); + } + solve.*.depth = solve.*.depth + 1; + solve.*.states.data[node_index] = 1; + solve.*.visited_nodes = solve.*.visited_nodes + 1; + if (node.kind == IntrinsicKind.leaf) { + solve.*.values.data[node_index] = node.metrics; + solve.*.states.data[node_index] = 2; + solve.*.depth = solve.*.depth - 1; + return node.metrics; + } + + let minimum_width: i64 = 0; + let minimum_height: i64 = 0; + let preferred_width: i64 = 0; + let preferred_height: i64 = 0; + let maximum_width: i64 = 0; + let maximum_height: i64 = 0; + let max_baseline: i64 = 0; + let max_descent: i64 = 0; + let first_baseline: i64 = 0; + let child_count: i32 = 0; + let edge_index: i32 = 0; + while (edge_index < tree.edges.len && solve.*.error == IntrinsicError.none) { + let edge: IntrinsicEdge = tree.edges.data[edge_index]; + if (edge.parent_id == node.id) { + let child_index: i32 = intrinsic_find_node(tree, edge.child_id); + if (child_index < 0) { + intrinsic_set_error(solve, IntrinsicError.missing_child, edge.child_id, edge_index); + return intrinsic_zero(); + } + let child: IntrinsicMetrics = intrinsic_compute(tree, child_index, solve); + if (solve.*.error != IntrinsicError.none) { return intrinsic_zero(); } + if (child_count == 0) { first_baseline = child.baseline; } + + if (node.kind == IntrinsicKind.row) { + if (child_count > 0) { + minimum_width = intrinsic_add(minimum_width, node.gap); + preferred_width = intrinsic_add(preferred_width, node.gap); + maximum_width = intrinsic_add_maximum(maximum_width, node.gap); + } + if (minimum_width >= 0) { minimum_width = intrinsic_add(minimum_width, child.minimum.width); } + if (preferred_width >= 0) { preferred_width = intrinsic_add(preferred_width, child.preferred.width); } + if (maximum_width >= 0) { + maximum_width = intrinsic_add_maximum(maximum_width, child.maximum.width); + } + minimum_height = intrinsic_max(minimum_height, child.minimum.height); + preferred_height = intrinsic_max(preferred_height, child.preferred.height); + maximum_height = intrinsic_max(maximum_height, child.maximum.height); + max_baseline = intrinsic_max(max_baseline, child.baseline); + max_descent = intrinsic_max(max_descent, child.preferred.height - child.baseline); + } else if (node.kind == IntrinsicKind.column) { + minimum_width = intrinsic_max(minimum_width, child.minimum.width); + preferred_width = intrinsic_max(preferred_width, child.preferred.width); + maximum_width = intrinsic_max(maximum_width, child.maximum.width); + if (child_count > 0) { + minimum_height = intrinsic_add(minimum_height, node.gap); + preferred_height = intrinsic_add(preferred_height, node.gap); + maximum_height = intrinsic_add_maximum(maximum_height, node.gap); + } + if (minimum_height >= 0) { minimum_height = intrinsic_add(minimum_height, child.minimum.height); } + if (preferred_height >= 0) { preferred_height = intrinsic_add(preferred_height, child.preferred.height); } + if (maximum_height >= 0) { + maximum_height = intrinsic_add_maximum(maximum_height, child.maximum.height); + } + } else { + minimum_width = intrinsic_max(minimum_width, child.minimum.width); + minimum_height = intrinsic_max(minimum_height, child.minimum.height); + preferred_width = intrinsic_max(preferred_width, child.preferred.width); + preferred_height = intrinsic_max(preferred_height, child.preferred.height); + maximum_width = intrinsic_max(maximum_width, child.maximum.width); + maximum_height = intrinsic_max(maximum_height, child.maximum.height); + max_baseline = intrinsic_max(max_baseline, child.baseline); + } + if (minimum_width < 0 || minimum_height < 0 || preferred_width < 0 || + preferred_height < 0 || maximum_width < 0 || maximum_height < 0) { + intrinsic_set_error(solve, IntrinsicError.arithmetic_overflow, node.id, edge_index); + return intrinsic_zero(); + } + child_count = child_count + 1; + } + edge_index = edge_index + 1; + } + + if (node.kind == IntrinsicKind.row) { + let baseline_extent: i64 = intrinsic_add(max_baseline, max_descent); + if (baseline_extent < 0) { + intrinsic_set_error(solve, IntrinsicError.arithmetic_overflow, node.id, 0 - 1); + return intrinsic_zero(); + } + preferred_height = intrinsic_max(preferred_height, baseline_extent); + } + + let horizontal: i64 = intrinsic_add(node.padding.left, node.padding.right); + let vertical: i64 = intrinsic_add(node.padding.top, node.padding.bottom); + if (horizontal < 0 || vertical < 0) { + intrinsic_set_error(solve, IntrinsicError.arithmetic_overflow, node.id, 0 - 1); + return intrinsic_zero(); + } + minimum_width = intrinsic_add(minimum_width, horizontal); + preferred_width = intrinsic_add(preferred_width, horizontal); + maximum_width = intrinsic_add_maximum(maximum_width, horizontal); + minimum_height = intrinsic_add(minimum_height, vertical); + preferred_height = intrinsic_add(preferred_height, vertical); + maximum_height = intrinsic_add_maximum(maximum_height, vertical); + if (minimum_width < 0 || minimum_height < 0 || preferred_width < 0 || + preferred_height < 0 || maximum_width < 0 || maximum_height < 0) { + intrinsic_set_error(solve, IntrinsicError.arithmetic_overflow, node.id, 0 - 1); + return intrinsic_zero(); + } + + let baseline: i64 = node.padding.top; + if (child_count > 0) { + if (node.kind == IntrinsicKind.row || node.kind == IntrinsicKind.overlay) { + baseline = intrinsic_add(node.padding.top, max_baseline); + } else { + baseline = intrinsic_add(node.padding.top, first_baseline); + } + } + if (baseline < 0) { + intrinsic_set_error(solve, IntrinsicError.arithmetic_overflow, node.id, 0 - 1); + return intrinsic_zero(); + } + let result: IntrinsicMetrics = intrinsic_metrics( + minimum_width, minimum_height, + preferred_width, preferred_height, + maximum_width, maximum_height, + baseline + ); + if (intrinsic_metric_valid(result) == 0) { + intrinsic_set_error(solve, IntrinsicError.invalid_node, node.id, 0 - 1); + return intrinsic_zero(); + } + solve.*.values.data[node_index] = result; + solve.*.states.data[node_index] = 2; + solve.*.depth = solve.*.depth - 1; + return result; +} + +fn measure_error_result(error: IntrinsicError, node: i64, edge: i32) MeasureResult { + let result: MeasureResult = MeasureResult{ + .intrinsic = intrinsic_zero(), + .resolved = size(0, 0), + .baseline = 0, + .overflow_width = 0, + .overflow_height = 0, + .width_rule = MeasureRule.intrinsic_preferred, + .height_rule = MeasureRule.intrinsic_preferred, + .visited_nodes = 0, + .measure_hash = 17, + .error = error, + .error_node = node, + .error_edge = edge, + }; + result.measure_hash = intrinsic_mix(result.measure_hash, error as i64); + result.measure_hash = intrinsic_mix(result.measure_hash, node); + result.measure_hash = intrinsic_mix(result.measure_hash, edge as i64); + return result; +} + +fn measure_rule(preferred: i64, minimum: i64, maximum: i64) MeasureRule { + if (preferred < minimum) { return MeasureRule.minimum_constraint; } + if (preferred > maximum) { return MeasureRule.maximum_constraint; } + return MeasureRule.intrinsic_preferred; +} + +fn intrinsic_tree_measure(tree: IntrinsicTree, root_id: i64, raw_constraints: Constraints) MeasureResult { + if (tree.nodes.len > intrinsic_node_limit() || tree.edges.len > intrinsic_edge_limit()) { + return measure_error_result(IntrinsicError.too_many_nodes, root_id, 0 - 1); + } + let constraints: Constraints = normalize_constraints(raw_constraints); + if (constraints.min_width > unbounded_extent() || constraints.max_width > unbounded_extent() || + constraints.min_height > unbounded_extent() || constraints.max_height > unbounded_extent()) { + return measure_error_result(IntrinsicError.invalid_constraints, root_id, 0 - 1); + } + let root_index: i32 = intrinsic_find_node(tree, root_id); + if (root_index < 0) { return measure_error_result(IntrinsicError.missing_node, root_id, 0 - 1); } + + let i: i32 = 0; + while (i < tree.nodes.len) { + if (intrinsic_node_valid(tree.nodes.data[i]) == 0) { + return measure_error_result(IntrinsicError.invalid_node, tree.nodes.data[i].id, 0 - 1); + } + let j: i32 = i + 1; + while (j < tree.nodes.len) { + if (tree.nodes.data[i].id == tree.nodes.data[j].id) { + return measure_error_result(IntrinsicError.duplicate_node, tree.nodes.data[j].id, j); + } + j = j + 1; + } + i = i + 1; + } + let edge_index: i32 = 0; + while (edge_index < tree.edges.len) { + let edge: IntrinsicEdge = tree.edges.data[edge_index]; + let parent_index: i32 = intrinsic_find_node(tree, edge.parent_id); + let child_index: i32 = intrinsic_find_node(tree, edge.child_id); + if (parent_index < 0) { + return measure_error_result(IntrinsicError.missing_node, edge.parent_id, edge_index); + } + if (child_index < 0) { + return measure_error_result(IntrinsicError.missing_child, edge.child_id, edge_index); + } + if (tree.nodes.data[parent_index].kind == IntrinsicKind.leaf) { + return measure_error_result(IntrinsicError.invalid_edge, edge.parent_id, edge_index); + } + if (edge.child_id == root_id) { + return measure_error_result(IntrinsicError.cycle, root_id, edge_index); + } + let prior: i32 = 0; + while (prior < edge_index) { + if (tree.edges.data[prior].parent_id == edge.parent_id && + tree.edges.data[prior].child_id == edge.child_id) { + return measure_error_result(IntrinsicError.invalid_edge, edge.child_id, edge_index); + } + if (tree.edges.data[prior].child_id == edge.child_id) { + return measure_error_result(IntrinsicError.multiple_parents, edge.child_id, edge_index); + } + prior = prior + 1; + } + edge_index = edge_index + 1; + } + + let solve: IntrinsicSolveState = IntrinsicSolveState{ + .states = make[i32](tree.nodes.len), + .values = make[IntrinsicMetrics](tree.nodes.len), + .visited_nodes = 0, + .error = IntrinsicError.none, + .error_node = 0, + .error_edge = 0 - 1, + .depth = 0, + }; + i = 0; + while (i < tree.nodes.len) { + push[i32](&solve.states, 0); + push[IntrinsicMetrics](&solve.values, intrinsic_zero()); + i = i + 1; + } + let metrics: IntrinsicMetrics = intrinsic_compute(tree, root_index, &solve); + if (solve.error != IntrinsicError.none) { + let failed: MeasureResult = measure_error_result(solve.error, solve.error_node, solve.error_edge); + failed.visited_nodes = solve.visited_nodes; + failed.measure_hash = intrinsic_mix(failed.measure_hash, failed.visited_nodes as i64); + free[i32](&solve.states); + free[IntrinsicMetrics](&solve.values); + return failed; + } + if (solve.visited_nodes != tree.nodes.len) { + i = 0; + while (i < solve.states.len && solve.states.data[i] != 0) { i = i + 1; } + let orphan_id: i64 = 0; + if (i < tree.nodes.len) { orphan_id = tree.nodes.data[i].id; } + let failed: MeasureResult = measure_error_result(IntrinsicError.orphan_node, orphan_id, 0 - 1); + failed.visited_nodes = solve.visited_nodes; + failed.measure_hash = intrinsic_mix(failed.measure_hash, failed.visited_nodes as i64); + free[i32](&solve.states); + free[IntrinsicMetrics](&solve.values); + return failed; + } + + let resolved: Size = size( + clamp_i64(metrics.preferred.width, constraints.min_width, constraints.max_width), + clamp_i64(metrics.preferred.height, constraints.min_height, constraints.max_height) + ); + let overflow_width: i64 = metrics.minimum.width - resolved.width; + if (overflow_width < 0) { overflow_width = 0; } + let overflow_height: i64 = metrics.minimum.height - resolved.height; + if (overflow_height < 0) { overflow_height = 0; } + let result: MeasureResult = MeasureResult{ + .intrinsic = metrics, + .resolved = resolved, + .baseline = clamp_i64(metrics.baseline, 0, resolved.height), + .overflow_width = overflow_width, + .overflow_height = overflow_height, + .width_rule = measure_rule(metrics.preferred.width, constraints.min_width, constraints.max_width), + .height_rule = measure_rule(metrics.preferred.height, constraints.min_height, constraints.max_height), + .visited_nodes = solve.visited_nodes, + .measure_hash = intrinsic_mix(intrinsic_tree_hash(tree), intrinsic_metrics_hash(metrics)), + .error = IntrinsicError.none, + .error_node = 0, + .error_edge = 0 - 1, + }; + result.measure_hash = intrinsic_mix(result.measure_hash, resolved.width); + result.measure_hash = intrinsic_mix(result.measure_hash, resolved.height); + result.measure_hash = intrinsic_mix(result.measure_hash, result.baseline); + result.measure_hash = intrinsic_mix(result.measure_hash, result.overflow_width); + result.measure_hash = intrinsic_mix(result.measure_hash, result.overflow_height); + result.measure_hash = intrinsic_mix(result.measure_hash, result.width_rule as i64); + result.measure_hash = intrinsic_mix(result.measure_hash, result.height_rule as i64); + result.measure_hash = intrinsic_mix(result.measure_hash, result.visited_nodes as i64); + result.measure_hash = intrinsic_mix(result.measure_hash, constraints.min_width); + result.measure_hash = intrinsic_mix(result.measure_hash, constraints.max_width); + result.measure_hash = intrinsic_mix(result.measure_hash, constraints.min_height); + result.measure_hash = intrinsic_mix(result.measure_hash, constraints.max_height); + free[i32](&solve.states); + free[IntrinsicMetrics](&solve.values); + return result; +} diff --git a/src/replay/replay.zag b/src/replay/replay.zag index 7a805df..8d9da04 100644 --- a/src/replay/replay.zag +++ b/src/replay/replay.zag @@ -68,6 +68,7 @@ struct ReplayResult { motion_velocity: i64, motion_status: MotionStatus, semantics_hash: i64, + measure_hash: i64, layout_hash: i64, layout_lines: i32, display_hash: i64, @@ -241,6 +242,7 @@ fn replay_result_error(error: ReplayError, event_index: i32, applied: i64) Repla .motion_velocity = 0, .motion_status = MotionStatus.settled, .semantics_hash = 0, + .measure_hash = 0, .layout_hash = 0, .layout_lines = 0, .display_hash = 0, @@ -352,15 +354,54 @@ fn replay_build_scene(state_value: i64, backend_generation: i64) ReplayResult { let magnitude: i64 = state_value % 3; if (magnitude < 0) { magnitude = 0 - magnitude; } + let first_metrics: IntrinsicMetrics = intrinsic_metrics( + (2 + magnitude) * unit_scale(), 4 * unit_scale(), + (2 + magnitude) * unit_scale(), 4 * unit_scale(), + (2 + magnitude) * unit_scale(), 4 * unit_scale(), + 4 * unit_scale() + ); + let second_metrics: IntrinsicMetrics = intrinsic_metrics( + 2 * unit_scale(), 4 * unit_scale(), + 2 * unit_scale(), 4 * unit_scale(), + 2 * unit_scale(), 4 * unit_scale(), + 4 * unit_scale() + ); + let third_metrics: IntrinsicMetrics = intrinsic_metrics( + 2 * unit_scale(), 2 * unit_scale(), + 2 * unit_scale(), 2 * unit_scale(), + 2 * unit_scale(), 2 * unit_scale(), + 2 * unit_scale() + ); + let measurement: IntrinsicTree = intrinsic_tree_make(); + intrinsic_tree_add_node( + &measurement, intrinsic_group(1000, IntrinsicKind.row, unit_scale(), insets_all(0)) + ); + intrinsic_tree_add_node(&measurement, intrinsic_leaf(101, first_metrics, magnitude)); + intrinsic_tree_add_node(&measurement, intrinsic_leaf(102, second_metrics, 0)); + intrinsic_tree_add_node(&measurement, intrinsic_leaf(103, third_metrics, 0)); + intrinsic_tree_add_child(&measurement, 1000, 101); + intrinsic_tree_add_child(&measurement, 1000, 102); + intrinsic_tree_add_child(&measurement, 1000, 103); + let measured: MeasureResult = intrinsic_tree_measure( + measurement, 1000, + Constraints{ + .min_width = 0, .max_width = unbounded_extent(), + .min_height = 0, .max_height = unbounded_extent(), + } + ); + intrinsic_tree_free(&measurement); + if (measured.error != IntrinsicError.none || measured.visited_nodes != 4) { + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } + result.measure_hash = measured.measure_hash; + let items: ArrayList[FlexItem] = make[FlexItem](3); - let first: FlexItem = flex_item(101, (2 + magnitude) * unit_scale(), 4 * unit_scale()); - first.shrink = 0; + let first: FlexItem = flex_item_intrinsic(101, first_metrics, FlexAxis.row, FlexPriority.required); push[FlexItem](&items, first); - let second: FlexItem = flex_item(102, 2 * unit_scale(), 4 * unit_scale()); - second.shrink = 0; + let second: FlexItem = flex_item_intrinsic(102, second_metrics, FlexAxis.row, FlexPriority.required); push[FlexItem](&items, second); - let third: FlexItem = flex_item(103, 2 * unit_scale(), 2 * unit_scale()); - third.shrink = 0; + let third: FlexItem = flex_item_intrinsic(103, third_metrics, FlexAxis.row, FlexPriority.required); push[FlexItem](&items, third); let style: FlexStyle = flex_style(FlexAxis.row, unit_scale(), insets_all(0)); let wrap_style: FlexWrapStyle = flex_wrap_style(style, unit_scale()); @@ -530,6 +571,7 @@ fn replay_execute(tape: ReplayTape) ReplayResult { hash = display_mix(hash, result.input_hash); hash = display_mix(hash, result.motion_hash); hash = display_mix(hash, result.semantics_hash); + hash = display_mix(hash, result.measure_hash); hash = display_mix(hash, result.layout_hash); hash = display_mix(hash, result.display_hash); result.output_hash = display_mix(hash, result.cpu_hash); diff --git a/tests/measure_contract.zag b/tests/measure_contract.zag new file mode 100644 index 0000000..621c41a --- /dev/null +++ b/tests/measure_contract.zag @@ -0,0 +1,420 @@ +@import("std:list") +@import("../src/core/state.zag") +@import("../src/layout/invalidation.zag") +@import("../src/layout/flex.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn leaf_metrics(width: i64, height: i64, baseline: i64) IntrinsicMetrics { + return intrinsic_metrics( + width / 2, height / 2, + width, height, + width * 2, height * 2, + baseline + ); +} + +fn aggregation_contract(state: *TestState) void { + let tree: IntrinsicTree = intrinsic_tree_make(); + intrinsic_tree_add_node(&tree, intrinsic_group(100, IntrinsicKind.row, 2, insets_all(3))); + intrinsic_tree_add_node(&tree, intrinsic_leaf(1, leaf_metrics(20, 10, 7), 0)); + intrinsic_tree_add_node(&tree, intrinsic_leaf(2, leaf_metrics(30, 16, 11), 0)); + intrinsic_tree_add_child(&tree, 100, 1); + intrinsic_tree_add_child(&tree, 100, 2); + let row: MeasureResult = intrinsic_tree_measure( + tree, 100, + Constraints{ .min_width = 0, .max_width = unbounded_extent(), .min_height = 0, .max_height = unbounded_extent() } + ); + expect(state, row.error == IntrinsicError.none && row.visited_nodes == 3, + "intrinsic row visits its exact retained subtree"); + expect(state, row.intrinsic.minimum.width == 33 && row.intrinsic.preferred.width == 58 && + row.intrinsic.maximum.width == 108, + "row intrinsic widths aggregate children gaps and padding exactly"); + expect(state, row.intrinsic.preferred.height == 22 && row.baseline == 14, + "row baseline aggregation reserves ascent descent and physical padding"); + expect(state, row.resolved.width == 58 && row.resolved.height == 22 && + row.width_rule == MeasureRule.intrinsic_preferred && row.height_rule == MeasureRule.intrinsic_preferred, + "unbounded constraints preserve preferred intrinsic size"); + + let constrained: MeasureResult = intrinsic_tree_measure(tree, 100, tight_constraints(40, 12)); + expect(state, constrained.resolved.width == 40 && constrained.resolved.height == 12 && + constrained.width_rule == MeasureRule.maximum_constraint && + constrained.height_rule == MeasureRule.maximum_constraint, + "tight constraints retain the exact rule that overrode preferred size"); + expect(state, constrained.overflow_width == 0 && constrained.overflow_height == 2 && + constrained.baseline == 12, + "resolved baselines clamp safely while minimum overflow remains explicit"); + let expanded: MeasureResult = intrinsic_tree_measure( + tree, 100, + Constraints{ .min_width = 100, .max_width = 120, .min_height = 30, .max_height = 40 } + ); + expect(state, expanded.resolved.width == 100 && expanded.resolved.height == 30 && + expanded.width_rule == MeasureRule.minimum_constraint && + expanded.height_rule == MeasureRule.minimum_constraint, + "minimum constraints expand intrinsic preference with explicit per-axis rules"); + intrinsic_tree_free(&tree); +} + +fn column_overlay_contract(state: *TestState) void { + let tree: IntrinsicTree = intrinsic_tree_make(); + intrinsic_tree_add_node(&tree, intrinsic_group(200, IntrinsicKind.column, 4, insets_all(2))); + intrinsic_tree_add_node(&tree, intrinsic_group(201, IntrinsicKind.overlay, 0, insets_all(1))); + intrinsic_tree_add_node(&tree, intrinsic_leaf(1, leaf_metrics(20, 10, 6), 1)); + intrinsic_tree_add_node(&tree, intrinsic_leaf(2, leaf_metrics(30, 12, 8), 2)); + intrinsic_tree_add_node(&tree, intrinsic_leaf(3, leaf_metrics(8, 6, 4), 3)); + intrinsic_tree_add_child(&tree, 201, 1); + intrinsic_tree_add_child(&tree, 201, 2); + intrinsic_tree_add_child(&tree, 200, 201); + intrinsic_tree_add_child(&tree, 200, 3); + let result: MeasureResult = intrinsic_tree_measure( + tree, 200, + Constraints{ .min_width = 0, .max_width = unbounded_extent(), .min_height = 0, .max_height = unbounded_extent() } + ); + expect(state, result.error == IntrinsicError.none && result.visited_nodes == 5, + "nested column and overlay measurement traverses each retained node once"); + expect(state, result.intrinsic.preferred.width == 36 && result.intrinsic.preferred.height == 28, + "overlay max and column sum aggregation compose deterministically"); + expect(state, result.baseline == 11 && result.measure_hash != 0, + "column baseline follows its first child and measurement retains identity"); + + let repeated: MeasureResult = intrinsic_tree_measure( + tree, 200, + Constraints{ .min_width = 0, .max_width = unbounded_extent(), .min_height = 0, .max_height = unbounded_extent() } + ); + expect(state, repeated.measure_hash == result.measure_hash, + "identical intrinsic trees produce bit-identical measurement identity"); + let changed_constraints: MeasureResult = intrinsic_tree_measure( + tree, 200, + Constraints{ + .min_width = 0, .max_width = unbounded_extent() - 1, + .min_height = 0, .max_height = unbounded_extent(), + } + ); + expect(state, changed_constraints.resolved.width == result.resolved.width && + changed_constraints.measure_hash != result.measure_hash, + "constraint input remains visible in identity even when resolved size is unchanged"); + tree.nodes.data[4].metrics.preferred.width = 9; + tree.nodes.data[4].metrics.maximum.width = 16; + let changed: MeasureResult = intrinsic_tree_measure( + tree, 200, + Constraints{ .min_width = 0, .max_width = unbounded_extent(), .min_height = 0, .max_height = unbounded_extent() } + ); + expect(state, changed.measure_hash != result.measure_hash, + "intrinsic content changes alter deterministic measurement identity"); + intrinsic_tree_free(&tree); +} + +fn flex_priority_contract(state: *TestState) void { + let low_metrics: IntrinsicMetrics = intrinsic_metrics(10, 10, 30, 10, 60, 10, 8); + let high_metrics: IntrinsicMetrics = intrinsic_metrics(10, 10, 30, 10, 60, 10, 8); + let required_metrics: IntrinsicMetrics = intrinsic_metrics(20, 10, 20, 10, 20, 10, 8); + let items: ArrayList[FlexItem] = make[FlexItem](3); + push[FlexItem](&items, flex_item_intrinsic(1, low_metrics, FlexAxis.row, FlexPriority.low)); + push[FlexItem](&items, flex_item_intrinsic(2, high_metrics, FlexAxis.row, FlexPriority.high)); + push[FlexItem](&items, flex_item_intrinsic(3, required_metrics, FlexAxis.row, FlexPriority.required)); + expect(state, items.data[0].shrink == 4 && items.data[1].shrink == 1 && + items.data[2].shrink == 0 && items.data[2].min_main == 20, + "Flex priorities map to explicit shrink resistance and intrinsic bounds"); + let result: FlexResult = flex_layout( + items, tight_constraints(60, 10), flex_style(FlexAxis.row, 0, insets_all(0)) + ); + expect(state, result.error == FlexError.none && result.placements.data[2].frame.width == 20, + "required intrinsic items preserve their exact extent under pressure"); + expect(state, result.placements.data[0].frame.width < result.placements.data[1].frame.width, + "lower priority content yields more space than higher priority content"); + flex_free(&result); + free[FlexItem](&items); + + let column: FlexItem = flex_item_intrinsic(4, low_metrics, FlexAxis.column, FlexPriority.normal); + expect(state, column.basis == low_metrics.preferred.height && + column.min_main == low_metrics.minimum.height && column.max_main == low_metrics.maximum.height && + column.cross == low_metrics.preferred.width, + "column intrinsic conversion maps vertical main and horizontal cross axes explicitly"); +} + +fn invalidation_contract(state: *TestState) void { + let width: State[i64] = state_make[i64](700, 20); + let measure_context: ViewContext = view_context_make(node_key_scoped(10, 2)); + let layout_context: ViewContext = view_context_make(node_key_scoped(11, 4)); + _ = state_read[i64](&width, &measure_context); + _ = state_read[i64](&width, &layout_context); + let trace: LayoutTrace = layout_trace_make(); + layout_trace_record( + &trace, measure_context, node_key(100), LayoutPhase.measure, LayoutReason.intrinsic_content + ); + layout_trace_record( + &trace, layout_context, node_key(101), LayoutPhase.layout, LayoutReason.breakpoint + ); + expect(state, trace.dependencies.len == 2, + "layout trace retains independent measure and placement dependencies"); + layout_trace_record( + &trace, measure_context, node_key(100), LayoutPhase.measure, LayoutReason.intrinsic_content + ); + expect(state, trace.dependencies.len == 2, + "duplicate dependency reads collapse without losing their phase or rule"); + + let change: StateChange = state_set[i64](&width, 30); + let invalidated: LayoutInvalidationResult = layout_trace_invalidate(trace, change); + expect(state, invalidated.causes.len == 2, + "one state change reports every affected layout operation"); + expect(state, invalidated.causes.data[0].node.value == 10 && + invalidated.causes.data[0].node.generation == 2 && + invalidated.causes.data[0].ancestor.value == 100 && + invalidated.causes.data[0].phase == LayoutPhase.measure && + invalidated.causes.data[0].reason == LayoutReason.intrinsic_content, + "measurement invalidation names the exact reader generation ancestor phase and rule"); + expect(state, invalidated.causes.data[1].node.value == 11 && + invalidated.causes.data[1].ancestor.value == 101 && + invalidated.causes.data[1].phase == LayoutPhase.layout && + invalidated.causes.data[1].reason == LayoutReason.breakpoint && + invalidated.causes.data[1].read_revision == 0 && invalidated.causes.data[1].to_revision == 1, + "placement invalidation retains the exact state revision edge and breakpoint reason"); + layout_invalidation_free(&invalidated); + + let unrelated: LayoutInvalidationResult = layout_trace_invalidate( + trace, StateChange{ .state_id = 999, .from_revision = 0, .to_revision = 1 } + ); + expect(state, unrelated.causes.len == 0, + "unread state produces no phantom layout work"); + layout_invalidation_free(&unrelated); + layout_trace_free(&trace); + view_context_free(&measure_context); + view_context_free(&layout_context); +} + +fn failure_contract(state: *TestState) void { + let duplicate: IntrinsicTree = intrinsic_tree_make(); + intrinsic_tree_add_node(&duplicate, intrinsic_leaf(1, leaf_metrics(10, 10, 5), 0)); + intrinsic_tree_add_node(&duplicate, intrinsic_leaf(1, leaf_metrics(10, 10, 5), 0)); + let duplicate_result: MeasureResult = intrinsic_tree_measure(duplicate, 1, tight_constraints(10, 10)); + expect(state, duplicate_result.error == IntrinsicError.duplicate_node && + duplicate_result.error_node == 1 && duplicate_result.error_edge == 1, + "duplicate retained IDs fail at the exact node before measurement"); + intrinsic_tree_free(&duplicate); + + let missing: IntrinsicTree = intrinsic_tree_make(); + intrinsic_tree_add_node(&missing, intrinsic_group(10, IntrinsicKind.row, 0, insets_all(0))); + intrinsic_tree_add_child(&missing, 10, 99); + let missing_result: MeasureResult = intrinsic_tree_measure(missing, 10, tight_constraints(10, 10)); + expect(state, missing_result.error == IntrinsicError.missing_child && + missing_result.error_node == 99 && missing_result.error_edge == 0, + "missing intrinsic children fail at the exact edge"); + intrinsic_tree_free(&missing); + + let missing_parent: IntrinsicTree = intrinsic_tree_make(); + intrinsic_tree_add_node(&missing_parent, intrinsic_leaf(11, leaf_metrics(10, 10, 5), 0)); + intrinsic_tree_add_child(&missing_parent, 99, 11); + let missing_parent_result: MeasureResult = intrinsic_tree_measure( + missing_parent, 11, tight_constraints(10, 10) + ); + expect(state, missing_parent_result.error == IntrinsicError.missing_node && + missing_parent_result.error_node == 99 && missing_parent_result.error_edge == 0, + "missing intrinsic parents fail as missing retained nodes rather than child errors"); + intrinsic_tree_free(&missing_parent); + + let orphan: IntrinsicTree = intrinsic_tree_make(); + intrinsic_tree_add_node(&orphan, intrinsic_group(10, IntrinsicKind.row, 0, insets_all(0))); + intrinsic_tree_add_node(&orphan, intrinsic_leaf(11, leaf_metrics(10, 10, 5), 0)); + let orphan_result: MeasureResult = intrinsic_tree_measure(orphan, 10, tight_constraints(10, 10)); + expect(state, orphan_result.error == IntrinsicError.orphan_node && + orphan_result.error_node == 11 && orphan_result.visited_nodes == 1, + "retained intrinsic nodes cannot silently remain outside the measured root tree"); + intrinsic_tree_free(&orphan); + + let shared: IntrinsicTree = intrinsic_tree_make(); + intrinsic_tree_add_node(&shared, intrinsic_group(10, IntrinsicKind.row, 0, insets_all(0))); + intrinsic_tree_add_node(&shared, intrinsic_group(11, IntrinsicKind.column, 0, insets_all(0))); + intrinsic_tree_add_node(&shared, intrinsic_leaf(12, leaf_metrics(10, 10, 5), 0)); + intrinsic_tree_add_child(&shared, 10, 12); + intrinsic_tree_add_child(&shared, 11, 12); + intrinsic_tree_add_child(&shared, 10, 11); + let shared_result: MeasureResult = intrinsic_tree_measure(shared, 10, tight_constraints(10, 10)); + expect(state, shared_result.error == IntrinsicError.multiple_parents && + shared_result.error_node == 12 && shared_result.error_edge == 1, + "one retained intrinsic node cannot have multiple layout owners"); + intrinsic_tree_free(&shared); + + let leaf_parent: IntrinsicTree = intrinsic_tree_make(); + intrinsic_tree_add_node(&leaf_parent, intrinsic_leaf(10, leaf_metrics(10, 10, 5), 0)); + intrinsic_tree_add_node(&leaf_parent, intrinsic_leaf(11, leaf_metrics(10, 10, 5), 0)); + intrinsic_tree_add_child(&leaf_parent, 10, 11); + let leaf_parent_result: MeasureResult = intrinsic_tree_measure( + leaf_parent, 10, tight_constraints(10, 10) + ); + expect(state, leaf_parent_result.error == IntrinsicError.invalid_edge && + leaf_parent_result.error_node == 10 && leaf_parent_result.error_edge == 0, + "leaf nodes cannot masquerade as intrinsic layout containers"); + intrinsic_tree_free(&leaf_parent); + + let cycle: IntrinsicTree = intrinsic_tree_make(); + intrinsic_tree_add_node(&cycle, intrinsic_group(10, IntrinsicKind.row, 0, insets_all(0))); + intrinsic_tree_add_node(&cycle, intrinsic_group(11, IntrinsicKind.column, 0, insets_all(0))); + intrinsic_tree_add_child(&cycle, 10, 11); + intrinsic_tree_add_child(&cycle, 11, 10); + let cycle_result: MeasureResult = intrinsic_tree_measure(cycle, 10, tight_constraints(10, 10)); + expect(state, cycle_result.error == IntrinsicError.cycle && cycle_result.error_node == 10 && + cycle_result.error_edge == 1, + "intrinsic cycles fail visibly without recursive runaway"); + intrinsic_tree_free(&cycle); + + let malformed: IntrinsicTree = intrinsic_tree_make(); + intrinsic_tree_add_node(&malformed, intrinsic_leaf( + 1, intrinsic_metrics(20, 10, 10, 10, 30, 20, 5), 0 + )); + let malformed_result: MeasureResult = intrinsic_tree_measure(malformed, 1, tight_constraints(10, 10)); + expect(state, malformed_result.error == IntrinsicError.invalid_node && malformed_result.error_node == 1, + "nonmonotonic intrinsic ranges fail before constraint resolution"); + intrinsic_tree_free(&malformed); + + let overflow: IntrinsicTree = intrinsic_tree_make(); + intrinsic_tree_add_node(&overflow, intrinsic_group(10, IntrinsicKind.row, 0, insets_all(0))); + intrinsic_tree_add_node(&overflow, intrinsic_leaf( + 1, intrinsic_metrics(unbounded_extent(), 1, unbounded_extent(), 1, unbounded_extent(), 1, 1), 0 + )); + intrinsic_tree_add_node(&overflow, intrinsic_leaf( + 2, intrinsic_metrics(1, 1, 1, 1, 1, 1, 1), 0 + )); + intrinsic_tree_add_child(&overflow, 10, 1); + intrinsic_tree_add_child(&overflow, 10, 2); + let overflow_result: MeasureResult = intrinsic_tree_measure( + overflow, 10, + Constraints{ .min_width = 0, .max_width = unbounded_extent(), .min_height = 0, .max_height = unbounded_extent() } + ); + expect(state, overflow_result.error == IntrinsicError.arithmetic_overflow && + overflow_result.error_node == 10 && overflow_result.error_edge == 1, + "finite intrinsic aggregation cannot wrap bounded geometry"); + intrinsic_tree_free(&overflow); + + let deep: IntrinsicTree = intrinsic_tree_make(); + let node_id: i64 = 1; + while (node_id <= (intrinsic_depth_limit() as i64) + 1) { + if (node_id == (intrinsic_depth_limit() as i64) + 1) { + intrinsic_tree_add_node(&deep, intrinsic_leaf(node_id, leaf_metrics(1, 1, 1), 0)); + } else { + intrinsic_tree_add_node(&deep, intrinsic_group(node_id, IntrinsicKind.column, 0, insets_all(0))); + intrinsic_tree_add_child(&deep, node_id, node_id + 1); + } + node_id = node_id + 1; + } + let deep_result: MeasureResult = intrinsic_tree_measure( + deep, 1, + Constraints{ + .min_width = 0, .max_width = unbounded_extent(), + .min_height = 0, .max_height = unbounded_extent(), + } + ); + expect(state, deep_result.error == IntrinsicError.depth_exceeded && + deep_result.error_node == (intrinsic_depth_limit() as i64) + 1 && + deep_result.visited_nodes == intrinsic_depth_limit(), + "adversarial retained depth fails before exhausting the native call stack"); + intrinsic_tree_free(&deep); +} + +fn stability_contract(state: *TestState) void { + let tree: IntrinsicTree = intrinsic_tree_make(); + intrinsic_tree_add_node(&tree, intrinsic_leaf(1, leaf_metrics(20, 10, 7), 4)); + let stability: IntrinsicStability = intrinsic_stability_make(); + let first: IntrinsicStabilityResult = intrinsic_stability_observe(&stability, tree); + expect(state, first.error == IntrinsicError.none && stability.witnesses.len == 1, + "first intrinsic observation retains a revisioned stability witness"); + + tree.nodes.data[0].metrics.preferred.width = 21; + let unstable: IntrinsicStabilityResult = intrinsic_stability_observe(&stability, tree); + expect(state, unstable.error == IntrinsicError.unstable_measurement && unstable.node_id == 1 && + unstable.previous_hash != unstable.current_hash, + "changed intrinsic input at one revision fails as unstable measurement"); + + tree.nodes.data[0].revision = 5; + let revised: IntrinsicStabilityResult = intrinsic_stability_observe(&stability, tree); + expect(state, revised.error == IntrinsicError.none && stability.witnesses.data[0].revision == 5, + "an explicit content revision permits and records changed intrinsic input"); + tree.nodes.data[0].revision = 4; + let stale: IntrinsicStabilityResult = intrinsic_stability_observe(&stability, tree); + expect(state, stale.error == IntrinsicError.invalid_node && stale.node_id == 1, + "intrinsic content revisions cannot regress behind retained stability truth"); + intrinsic_stability_free(&stability); + intrinsic_tree_free(&tree); +} + +fn property_contract(state: *TestState) void { + let all_valid: i32 = 1; + let width: i64 = 1; + while (width <= 256) { + let tree: IntrinsicTree = intrinsic_tree_make(); + intrinsic_tree_add_node(&tree, intrinsic_leaf(1, leaf_metrics(100, 40, 25), width)); + let result: MeasureResult = intrinsic_tree_measure(tree, 1, tight_constraints(width, width / 2)); + if (result.error != IntrinsicError.none || result.resolved.width != width || + result.resolved.height != width / 2 || result.baseline > result.resolved.height) { + all_valid = 0; + } + intrinsic_tree_free(&tree); + width = width + 1; + } + expect(state, all_valid == 1, + "256-constraint sweep preserves exact bounded size and baseline invariants"); + + let all_rows_valid: i32 = 1; + let count: i64 = 1; + while (count <= 32) { + let gap: i64 = 0; + while (gap <= 8) { + let tree: IntrinsicTree = intrinsic_tree_make(); + intrinsic_tree_add_node(&tree, intrinsic_group(1000, IntrinsicKind.row, gap, insets_all(1))); + let expected_width: i64 = 2; + let child: i64 = 1; + while (child <= count) { + let width: i64 = (child % 7) + 1; + expected_width = expected_width + width; + if (child > 1) { expected_width = expected_width + gap; } + intrinsic_tree_add_node(&tree, intrinsic_leaf( + child, intrinsic_metrics(width, 1, width, 2, width, 3, 1), child + )); + intrinsic_tree_add_child(&tree, 1000, child); + child = child + 1; + } + let measured: MeasureResult = intrinsic_tree_measure( + tree, 1000, + Constraints{ + .min_width = 0, .max_width = unbounded_extent(), + .min_height = 0, .max_height = unbounded_extent(), + } + ); + if (measured.error != IntrinsicError.none || + measured.intrinsic.preferred.width != expected_width || + measured.visited_nodes != count + 1) { all_rows_valid = 0; } + intrinsic_tree_free(&tree); + gap = gap + 1; + } + count = count + 1; + } + expect(state, all_rows_valid == 1, + "288-tree aggregation sweep conserves child extents gaps padding and ownership"); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + aggregation_contract(&state); + column_overlay_contract(&state); + flex_priority_contract(&state); + invalidation_contract(&state); + failure_contract(&state); + stability_contract(&state); + property_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Measurement contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tests/replay_contract.zag b/tests/replay_contract.zag index 5dfee34..d0855aa 100644 --- a/tests/replay_contract.zag +++ b/tests/replay_contract.zag @@ -38,7 +38,8 @@ fn results_equal(a: ReplayResult, b: ReplayResult) i32 { a.motion_hash == b.motion_hash && a.motion_value == b.motion_value && a.motion_velocity == b.motion_velocity && a.motion_status == b.motion_status && - a.semantics_hash == b.semantics_hash && a.layout_hash == b.layout_hash && + a.semantics_hash == b.semantics_hash && a.measure_hash == b.measure_hash && + a.layout_hash == b.layout_hash && a.layout_lines == b.layout_lines && a.display_hash == b.display_hash && a.cpu_hash == b.cpu_hash && a.output_hash == b.output_hash) as i32; @@ -57,9 +58,10 @@ fn deterministic_contract(state: *TestState) void { expect(state, first.backend_id == 7 && first.backend_generation == 2 && first.backend_state == ReplayBackendState.active, "backend activation loss and recovery remain observable in final truth"); - expect(state, first.input_hash != 0 && first.motion_hash != 0 && first.semantics_hash != 0 && first.layout_hash != 0 && + expect(state, first.input_hash != 0 && first.motion_hash != 0 && first.semantics_hash != 0 && + first.measure_hash != 0 && first.layout_hash != 0 && first.display_hash != 0 && first.cpu_hash != 0 && first.output_hash != 0, - "replay regenerates input motion semantics layout display and CPU identities"); + "replay regenerates input motion semantics measurement layout display and CPU identities"); expect(state, first.motion_value > 0 && first.motion_velocity > 0 && first.motion_status == MotionStatus.running, "replayed clock samples advance interruptible motion state"); @@ -154,12 +156,14 @@ fn differential_contract(state: *TestState) void { let a: ReplayResult = replay_execute(baseline); let b: ReplayResult = replay_execute(changed_state); let c: ReplayResult = replay_execute(changed_pointer); - expect(state, a.output_hash != b.output_hash && a.motion_hash != b.motion_hash && a.semantics_hash != b.semantics_hash && + expect(state, a.output_hash != b.output_hash && a.motion_hash != b.motion_hash && + a.semantics_hash != b.semantics_hash && a.measure_hash != b.measure_hash && a.layout_hash != b.layout_hash && a.display_hash != b.display_hash && a.cpu_hash != b.cpu_hash, - "state changes propagate through motion semantics Flex display and CPU output"); + "state changes propagate through motion semantics measurement Flex display and CPU output"); expect(state, a.layout_lines != b.layout_lines, "state replay crosses Flex wrapping boundaries deterministically"); - expect(state, a.input_hash != c.input_hash && a.motion_hash == c.motion_hash && a.semantics_hash == c.semantics_hash && + expect(state, a.input_hash != c.input_hash && a.motion_hash == c.motion_hash && + a.semantics_hash == c.semantics_hash && a.measure_hash == c.measure_hash && a.layout_hash == c.layout_hash && a.display_hash == c.display_hash && a.cpu_hash == c.cpu_hash && a.output_hash != c.output_hash, "pointer differences change input identity without contaminating scene generation"); diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 680c808..d46c795 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -18,6 +18,8 @@ cd "$root" "$tmp/flex-contract" "$znc" tests/flex_adaptive_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/flex-adaptive-contract" "$tmp/flex-adaptive-contract" +"$znc" tests/measure_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/measure-contract" +"$tmp/measure-contract" "$znc" tests/state_reconcile_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/state-reconcile-contract" "$tmp/state-reconcile-contract" "$znc" tests/semantics_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/semantics-contract" @@ -37,4 +39,4 @@ cd "$root" "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (state, reconciliation, constraints, Flex, semantics, Talkback, display lists, CPU raster, input, replay, and motion)\n' +printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, semantics, Talkback, display lists, CPU raster, input, replay, and motion)\n' From dc55c5eb1f1e2450d6dd6931f6030091ceaeda59 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 08:02:39 -0700 Subject: [PATCH 018/103] Implement deterministic Grid and Overlay layout --- README.md | 18 +- docs/architecture/grid-and-overlay.md | 58 ++ docs/quality/release-gates.md | 7 +- .../0002-declarative-core-and-rendering.md | 4 + src/layout/common.zag | 3 + src/layout/flex.zag | 2 +- src/layout/grid.zag | 726 ++++++++++++++++++ src/layout/overlay.zag | 322 ++++++++ src/replay/replay.zag | 51 +- tests/grid_contract.zag | 208 +++++ tests/overlay_contract.zag | 203 +++++ tools/test-headless.sh | 6 +- 12 files changed, 1597 insertions(+), 11 deletions(-) create mode 100644 docs/architecture/grid-and-overlay.md create mode 100644 src/layout/common.zag create mode 100644 src/layout/grid.zag create mode 100644 src/layout/overlay.zag create mode 100644 tests/grid_contract.zag create mode 100644 tests/overlay_contract.zag diff --git a/README.md b/README.md index 51fb73a..ccc8220 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ modern materials and asset fidelity, and a complete PrismStudio UI replacement. This repository is at **0.1.0-experimental.0**. It currently contains the accepted product contract, executable Milestone 0 checks, and the first deterministic state, keyed reconciliation, geometry, intrinsic measurement, -Flex, semantics, Talkback, display-list, CPU-oracle, input, replay, and motion +Flex, Grid, Overlay, semantics, Talkback, display-list, CPU-oracle, input, replay, and motion slices. It does not yet contain a usable renderer, window shell, component library, or supported platform backend. Nothing in this repository is a Zagkit 1.0 release. @@ -42,7 +42,7 @@ is normative in [DEPENDENCIES.md](DEPENDENCIES.md). | Product and architecture contract | accepted | [RFC index](docs/rfcs/README.md) | | Compiler dependency | pinned, prerequisites incomplete | [toolchain lock](contracts/toolchain.json) | | Platform shells | unavailable | [support matrix](SUPPORT.md) | -| Headless core | experimental state, reconciliation, intrinsic measurement, geometry, Flex, semantics, Talkback, display lists, CPU raster, input, replay, and motion | [headless test](tools/test-headless.sh) | +| Headless core | experimental state, reconciliation, intrinsic measurement, geometry, Flex, Grid, Overlay, semantics, Talkback, display lists, CPU raster, input, replay, and motion | [headless test](tools/test-headless.sh) | | Components and visual language | inventory only, visual review pending | [component inventory](contracts/components.json) | | Flex and Zagkit Talkback | Flex foundation and in-process ID-first Talkback dispatch executing; native transport remains unavailable | [Talkback contract](docs/automation/talkback.md) | | Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | @@ -65,7 +65,7 @@ experimental. A retained intrinsic tree now aggregates leaf, row, column, and overlay size ranges, rejects invalid ownership and unstable same-revision measurement, resolves constraints with explicit rules and overflow, and records exact state-read layout causes. Typed environment values, reconciliation -cancellation, retained child lifecycle, replay serialization, grid and overlay +cancellation, retained child lifecycle, replay serialization, scroll and virtualized placement, and full breakpoint policy remain open; the corresponding Milestone 2 checklist items are not complete. See the [measurement contract](docs/architecture/measurement.md). @@ -77,9 +77,15 @@ column wrapping. Wrapped lines preserve logical stable-ID order while resolving physical RTL placement, per-line growth and shrinkage, baseline-safe extents, line alignment, overflow truth, and layout identity. These are headless placement primitives rather than the still-unselected visual design language. -Grid and overlay placement, end-to-end retained invalidation coverage, and the -full adaptive matrix remain open, so `G2-FLEX` and `G2-FLEX-RTL` are not complete. -See the [Flex contract](docs/architecture/flex.md). +Grid now resolves fixed, intrinsic, and weighted fraction tracks, spans, +row-major auto placement, explicit collision policy, baseline placement, +content distribution, quantified overflow, and logical RTL columns. Overlay +resolves intrinsic containers, padding, child insets, alignment, z-order, +baseline output, overflow, and RTL start/end. Both contribute deterministic +identity to replay. End-to-end retained invalidation coverage and the full +adaptive matrix remain open, so `G2-FLEX` and `G2-FLEX-RTL` are not complete. +See the [Flex contract](docs/architecture/flex.md) and +[Grid and Overlay contract](docs/architecture/grid-and-overlay.md). The semantics slice owns copied names and values, stable keys, explicit action capabilities, deterministic focus order, live-region state, ranges, selection, diff --git a/docs/architecture/grid-and-overlay.md b/docs/architecture/grid-and-overlay.md new file mode 100644 index 0000000..1d85921 --- /dev/null +++ b/docs/architecture/grid-and-overlay.md @@ -0,0 +1,58 @@ +# Grid and Overlay placement contract + +Status: experimental headless primitive + +Grid and Overlay are deterministic retained-placement primitives in Zagkit's +Flex layout system. They operate in signed 26.6 fixed-point logical units and +own their output arrays. They do not select a visual direction, create native +widgets, or imply that the higher-level Grid component is conformant. + +## Grid + +`GridTrack` has three explicit sizing modes: + +- fixed tracks never grow or shrink; +- intrinsic tracks start at their preferred extent and remain between their + declared minimum and maximum; +- fraction tracks start at their minimum and share remaining space by integer + weight, assigning division remainder deterministically. + +Spanning item minimum and preferred sizes pressure every eligible track in the +span before the container is resolved. Under a finite constraint, fraction +tracks grow first, `stretch` may grow remaining non-fixed tracks, and +non-fixed tracks shrink toward declared minima. Anything still outside the +container is reported by axis; fixed sizes are never silently falsified. + +Items use stable positive IDs. A pair of `-1` coordinates requests row-major +auto placement; partial automatic coordinates are invalid. Explicit overlap is +fail-closed unless `allow_overlap` is set, and the result then reports the +number of collided cells. Row and column spans, placement alignment, one-row +baseline alignment, z-order, content distribution, padding, gaps, intrinsic +container sizing, and logical RTL columns are part of the layout hash. + +## Overlay + +Overlay derives an unconstrained container from the largest child preferred +extent plus child insets and outer padding. Under finite constraints, each +child resolves independently with logical start, center, end, or stretch +alignment. Logical horizontal start and end mirror in RTL. Stable ID, z-order, +baseline output, per-axis minimum-size overflow, and input/output layout hashes +remain observable. + +Overlay rejects duplicate IDs, malformed intrinsic ranges, unsafe extents, +unsupported baseline placement requests, invalid constraints, and arithmetic +overflow before returning partial geometry. + +## Ownership, replay, and proof + +Call `grid_free` or `overlay_free` exactly once for every result, including +typed error results. Deterministic replay mixes Flex wrap, Grid, and Overlay +layout identities into the scene identity, so a placement change cannot hide +behind identical state or pixels. + +The focused executable contracts cover fixed, intrinsic, and fraction tracks; +spans; auto placement; collisions; opt-in overlap; baseline; z-order; RTL; +unbounded intrinsic sizing; quantified overflow; malformed input; deterministic +identity; 122 grid widths; and 253 overlay sizes. These tests are headless +correctness evidence. They are not screenshot, native accessibility, native +window, performance, or platform certification. diff --git a/docs/quality/release-gates.md b/docs/quality/release-gates.md index e72e0fa..4340b0e 100644 --- a/docs/quality/release-gates.md +++ b/docs/quality/release-gates.md @@ -37,8 +37,11 @@ Screenshots remain necessary visual evidence, not complete product evidence. ## Flex and Zagkit Talkback Flex conformance covers gap, padding, alignment, distribution, baseline, wrap, -intrinsic size, constraints, grid, overlay, breakpoints, safe areas, density, -RTL, and large text. Every layout change reports its exact state read and rule. +intrinsic size, constraints, grid tracks and spans, overlay placement, +breakpoints, safe areas, density, RTL, and large text. Grid collisions fail +closed unless overlap is explicit, and every primitive reports quantified +overflow plus deterministic identity. Every layout change reports its exact +state read and rule. Zagkit Talkback conformance drives canonical applications by stable ID and checks discovery, query, action, wait, assertion, screenshot, timeline, diff --git a/docs/rfcs/0002-declarative-core-and-rendering.md b/docs/rfcs/0002-declarative-core-and-rendering.md index 31782d6..7bb6f70 100644 --- a/docs/rfcs/0002-declarative-core-and-rendering.md +++ b/docs/rfcs/0002-declarative-core-and-rendering.md @@ -38,6 +38,10 @@ must be deterministic for the same inputs. Cycles, non-finite geometry, overflow, unstable intrinsic measurement, and duplicate keys fail with inspectable reasons. +The first executable Grid and Overlay interpretation of this decision is +documented in [the placement contract](../architecture/grid-and-overlay.md). +That experimental surface does not freeze the eventual declarative View API. + ## Rendering contract Rendering produces an immutable `DisplayList` of paths, paints, images, glyph diff --git a/src/layout/common.zag b/src/layout/common.zag new file mode 100644 index 0000000..1884d06 --- /dev/null +++ b/src/layout/common.zag @@ -0,0 +1,3 @@ +enum LayoutDirection { ltr, rtl } +enum PlacementAlignment { start, center, end, stretch, baseline } +enum ContentAlignment { start, center, end, space_between, space_around, space_evenly, stretch } diff --git a/src/layout/flex.zag b/src/layout/flex.zag index dcf4bf1..75ce0c4 100644 --- a/src/layout/flex.zag +++ b/src/layout/flex.zag @@ -1,9 +1,9 @@ @import("std:list") @import("../core/geometry.zag") +@import("common.zag") @import("measure.zag") enum FlexAxis { row, column } -enum LayoutDirection { ltr, rtl } enum MainAlignment { start, center, end, space_between, space_around, space_evenly } enum CrossAlignment { start, center, end, stretch, baseline } enum FlexError { none, invalid_constraints, invalid_style, invalid_item, arithmetic_overflow } diff --git a/src/layout/grid.zag b/src/layout/grid.zag new file mode 100644 index 0000000..97611de --- /dev/null +++ b/src/layout/grid.zag @@ -0,0 +1,726 @@ +@import("std:list") +@import("../core/geometry.zag") +@import("common.zag") +@import("measure.zag") + +enum GridTrackKind { fixed, intrinsic, fraction } +enum GridError { + none, + too_many_items, + too_many_tracks, + invalid_constraints, + invalid_style, + invalid_track, + invalid_item, + duplicate_id, + collision, + no_space, + arithmetic_overflow, +} + +struct GridTrack { + kind: GridTrackKind, + minimum: i64, + preferred: i64, + maximum: i64, + fraction: i64, +} + +struct GridItem { + id: i64, + row: i32, + column: i32, + row_span: i32, + column_span: i32, + metrics: IntrinsicMetrics, + horizontal: PlacementAlignment, + vertical: PlacementAlignment, + z_order: i64, +} + +struct GridStyle { + column_gap: i64, + row_gap: i64, + padding: Insets, + direction: LayoutDirection, + horizontal_content: ContentAlignment, + vertical_content: ContentAlignment, + allow_overlap: i32, +} + +struct GridCell { + row: i32, + column: i32, +} + +struct GridTrackPlacement { + offset: i64, + extent: i64, +} + +struct GridPlacement { + id: i64, + row: i32, + column: i32, + row_span: i32, + column_span: i32, + frame: Rect, + baseline: i64, + z_order: i64, +} + +struct GridResult { + placements: ArrayList[GridPlacement], + columns: ArrayList[GridTrackPlacement], + rows: ArrayList[GridTrackPlacement], + container: Size, + overflow_width: i64, + overflow_height: i64, + overlap_cells: i32, + layout_hash: i64, + error: GridError, + error_index: i32, +} + +struct GridDistribution { leading: i64, extra_gap: i64 } + +fn grid_fixed(value: i64) GridTrack { + return GridTrack{ .kind = GridTrackKind.fixed, .minimum = value, .preferred = value, + .maximum = value, .fraction = 0 }; +} + +fn grid_intrinsic(minimum: i64, preferred: i64, maximum: i64) GridTrack { + return GridTrack{ .kind = GridTrackKind.intrinsic, .minimum = minimum, + .preferred = preferred, .maximum = maximum, .fraction = 0 }; +} + +fn grid_fraction(fraction: i64, minimum: i64, maximum: i64) GridTrack { + return GridTrack{ .kind = GridTrackKind.fraction, .minimum = minimum, + .preferred = minimum, .maximum = maximum, .fraction = fraction }; +} + +fn grid_item(id: i64, metrics: IntrinsicMetrics) GridItem { + return GridItem{ + .id = id, .row = 0 - 1, .column = 0 - 1, .row_span = 1, .column_span = 1, + .metrics = metrics, .horizontal = PlacementAlignment.stretch, + .vertical = PlacementAlignment.stretch, .z_order = 0, + }; +} + +fn grid_item_at(id: i64, row: i32, column: i32, metrics: IntrinsicMetrics) GridItem { + let item: GridItem = grid_item(id, metrics); + item.row = row; + item.column = column; + return item; +} + +fn grid_style(column_gap: i64, row_gap: i64, padding: Insets) GridStyle { + return GridStyle{ + .column_gap = column_gap, .row_gap = row_gap, .padding = padding, + .direction = LayoutDirection.ltr, + .horizontal_content = ContentAlignment.start, + .vertical_content = ContentAlignment.start, + .allow_overlap = 0, + }; +} + +fn grid_free(result: *GridResult) void { + free[GridPlacement](&result.*.placements); + free[GridTrackPlacement](&result.*.columns); + free[GridTrackPlacement](&result.*.rows); +} + +fn grid_mix(hash: i64, value: i64) i64 { return (hash * 1099511628211) ^ value; } + +fn grid_error_result(error: GridError, index: i32) GridResult { + let result: GridResult = GridResult{ + .placements = make[GridPlacement](1), + .columns = make[GridTrackPlacement](1), + .rows = make[GridTrackPlacement](1), + .container = size(0, 0), .overflow_width = 0, .overflow_height = 0, + .overlap_cells = 0, .layout_hash = 17, .error = error, .error_index = index, + }; + result.layout_hash = grid_mix(grid_mix(result.layout_hash, error as i64), index as i64); + return result; +} + +fn grid_checked_add(a: i64, b: i64) i64 { + if (a < 0 || b < 0 || a > unbounded_extent() - b) { return 0 - 1; } + return a + b; +} + +fn grid_extent_valid(value: i64) i32 { + return (value >= 0 && value <= unbounded_extent() / 4) as i32; +} + +fn grid_insets_valid(value: Insets) i32 { + return (grid_extent_valid(value.top) == 1 && grid_extent_valid(value.right) == 1 && + grid_extent_valid(value.bottom) == 1 && grid_extent_valid(value.left) == 1) as i32; +} + +fn grid_track_valid(track: GridTrack) i32 { + if (track.minimum < 0 || track.preferred < track.minimum || + track.maximum < track.preferred || track.maximum > unbounded_extent()) { return 0; } + if (track.kind == GridTrackKind.fixed) { + return (track.minimum == track.preferred && track.preferred == track.maximum && + track.fraction == 0) as i32; + } + if (track.kind == GridTrackKind.intrinsic) { return (track.fraction == 0) as i32; } + return (track.fraction > 0 && track.fraction <= 4096 && + track.preferred == track.minimum) as i32; +} + +fn grid_item_valid(item: GridItem, row_count: i32, column_count: i32) i32 { + if (item.id <= 0 || intrinsic_metric_valid(item.metrics) == 0 || + item.row_span <= 0 || item.column_span <= 0 || + item.row_span > row_count || item.column_span > column_count || + item.horizontal == PlacementAlignment.baseline || + (item.vertical == PlacementAlignment.baseline && item.row_span != 1)) { return 0; } + let automatic: i32 = (item.row == 0 - 1 && item.column == 0 - 1) as i32; + let explicit: i32 = (item.row >= 0 && item.column >= 0 && + item.row + item.row_span <= row_count && + item.column + item.column_span <= column_count) as i32; + return (automatic == 1 || explicit == 1) as i32; +} + +fn grid_cells_free(occupancy: ArrayList[i32], columns: i32, row: i32, column: i32, + row_span: i32, column_span: i32) i32 { + let r: i32 = row; + while (r < row + row_span) { + let c: i32 = column; + while (c < column + column_span) { + if (occupancy.data[r * columns + c] != 0) { return 0; } + c = c + 1; + } + r = r + 1; + } + return 1; +} + +fn grid_mark_cells(occupancy: *ArrayList[i32], columns: i32, row: i32, column: i32, + row_span: i32, column_span: i32, marker: i32) i32 { + let overlaps: i32 = 0; + let r: i32 = row; + while (r < row + row_span) { + let c: i32 = column; + while (c < column + column_span) { + let index: i32 = r * columns + c; + if (occupancy.*.data[index] != 0) { overlaps = overlaps + 1; } + occupancy.*.data[index] = marker; + c = c + 1; + } + r = r + 1; + } + return overlaps; +} + +fn grid_track_initial(track: GridTrack) i64 { return track.preferred; } + +fn grid_span_extent(sizes: ArrayList[i64], start: i32, count: i32, gap: i64) i64 { + let extent: i64 = 0; + let i: i32 = 0; + while (i < count) { + extent = grid_checked_add(extent, sizes.data[start + i]); + if (extent < 0) { return 0 - 1; } + if (i > 0) { + extent = grid_checked_add(extent, gap); + if (extent < 0) { return 0 - 1; } + } + i = i + 1; + } + return extent; +} + +fn grid_grow_span(tracks: ArrayList[GridTrack], sizes: *ArrayList[i64], start: i32, + count: i32, gap: i64, target: i64) i32 { + let current: i64 = grid_span_extent(sizes.*, start, count, gap); + if (current < 0) { return 0; } + let remaining: i64 = target - current; + if (remaining <= 0) { return 1; } + let rounds: i32 = 0; + while (remaining > 0 && rounds < count + 1) { + let active: i32 = 0; + let i: i32 = 0; + while (i < count) { + let index: i32 = start + i; + if (tracks.data[index].kind != GridTrackKind.fixed && + sizes.*.data[index] < tracks.data[index].maximum) { active = active + 1; } + i = i + 1; + } + if (active == 0) { return 1; } + let before: i64 = remaining; + i = 0; + while (i < count && remaining > 0) { + let index: i32 = start + i; + if (tracks.data[index].kind != GridTrackKind.fixed && + sizes.*.data[index] < tracks.data[index].maximum) { + let share: i64 = remaining / (active as i64); + if (share < 1) { share = 1; } + let capacity: i64 = tracks.data[index].maximum - sizes.*.data[index]; + if (share > capacity) { share = capacity; } + if (share > remaining) { share = remaining; } + sizes.*.data[index] = sizes.*.data[index] + share; + remaining = remaining - share; + active = active - 1; + } + i = i + 1; + } + if (remaining == before) { return 1; } + rounds = rounds + 1; + } + return 1; +} + +fn grid_axis_prepare(items: ArrayList[GridItem], cells: ArrayList[GridCell], + tracks: ArrayList[GridTrack], sizes: *ArrayList[i64], gap: i64, horizontal: i32) i32 { + let i: i32 = 0; + while (i < tracks.len) { push[i64](sizes, grid_track_initial(tracks.data[i])); i = i + 1; } + i = 0; + while (i < items.len) { + let start: i32 = cells.data[i].row; + let count: i32 = items.data[i].row_span; + let minimum: i64 = items.data[i].metrics.minimum.height; + let preferred: i64 = items.data[i].metrics.preferred.height; + if (horizontal != 0) { + start = cells.data[i].column; + count = items.data[i].column_span; + minimum = items.data[i].metrics.minimum.width; + preferred = items.data[i].metrics.preferred.width; + } + if (grid_grow_span(tracks, sizes, start, count, gap, minimum) == 0 || + grid_grow_span(tracks, sizes, start, count, gap, preferred) == 0) { return i; } + i = i + 1; + } + return 0 - 1; +} + +fn grid_sizes_sum(sizes: ArrayList[i64]) i64 { + let total: i64 = 0; + let i: i32 = 0; + while (i < sizes.len) { + total = grid_checked_add(total, sizes.data[i]); + if (total < 0) { return 0 - 1; } + i = i + 1; + } + return total; +} + +fn grid_grow_available(tracks: ArrayList[GridTrack], sizes: *ArrayList[i64], extra: i64, + fraction_only: i32) i64 { + let remaining: i64 = extra; + let rounds: i32 = 0; + while (remaining > 0 && rounds < tracks.len + 1) { + let weight: i64 = 0; + let last_eligible: i32 = 0 - 1; + let i: i32 = 0; + while (i < tracks.len) { + let eligible: i32 = (sizes.*.data[i] < tracks.data[i].maximum && + tracks.data[i].kind != GridTrackKind.fixed) as i32; + if (eligible == 1 && (fraction_only == 0 || tracks.data[i].kind == GridTrackKind.fraction)) { + if (fraction_only != 0) { weight = weight + tracks.data[i].fraction; } + else { weight = weight + 1; } + last_eligible = i; + } + i = i + 1; + } + if (weight == 0) { return remaining; } + let before: i64 = remaining; + i = 0; + while (i < tracks.len && remaining > 0) { + let eligible: i32 = (sizes.*.data[i] < tracks.data[i].maximum && + tracks.data[i].kind != GridTrackKind.fixed) as i32; + if (eligible == 1 && (fraction_only == 0 || tracks.data[i].kind == GridTrackKind.fraction)) { + let item_weight: i64 = 1; + if (fraction_only != 0) { item_weight = tracks.data[i].fraction; } + let share: i64 = (before * item_weight) / weight; + if (i == last_eligible) { share = remaining; } + let capacity: i64 = tracks.data[i].maximum - sizes.*.data[i]; + if (share > capacity) { share = capacity; } + if (share > remaining) { share = remaining; } + sizes.*.data[i] = sizes.*.data[i] + share; + remaining = remaining - share; + } + i = i + 1; + } + if (remaining == before) { return remaining; } + rounds = rounds + 1; + } + return remaining; +} + +fn grid_shrink_available(tracks: ArrayList[GridTrack], sizes: *ArrayList[i64], need: i64) i64 { + let remaining: i64 = need; + let rounds: i32 = 0; + while (remaining > 0 && rounds < tracks.len + 1) { + let active: i32 = 0; + let i: i32 = 0; + while (i < tracks.len) { + if (tracks.data[i].kind != GridTrackKind.fixed && + sizes.*.data[i] > tracks.data[i].minimum) { active = active + 1; } + i = i + 1; + } + if (active == 0) { return remaining; } + let before: i64 = remaining; + i = 0; + while (i < tracks.len && remaining > 0) { + if (tracks.data[i].kind != GridTrackKind.fixed && + sizes.*.data[i] > tracks.data[i].minimum) { + let share: i64 = remaining / (active as i64); + if (share < 1) { share = 1; } + let capacity: i64 = sizes.*.data[i] - tracks.data[i].minimum; + if (share > capacity) { share = capacity; } + if (share > remaining) { share = remaining; } + sizes.*.data[i] = sizes.*.data[i] - share; + remaining = remaining - share; + active = active - 1; + } + i = i + 1; + } + if (remaining == before) { return remaining; } + rounds = rounds + 1; + } + return remaining; +} + +fn grid_adjust_axis(tracks: ArrayList[GridTrack], sizes: *ArrayList[i64], available: i64, + alignment: ContentAlignment) i64 { + let used: i64 = grid_sizes_sum(sizes.*); + if (used < 0) { return 0 - 1; } + if (used < available) { + let remaining: i64 = grid_grow_available(tracks, sizes, available - used, 1); + if (alignment == ContentAlignment.stretch && remaining > 0) { + _ = grid_grow_available(tracks, sizes, remaining, 0); + } + } else if (used > available) { + _ = grid_shrink_available(tracks, sizes, used - available); + } + return grid_sizes_sum(sizes.*); +} + +fn grid_distribution(free_space: i64, count: i32, alignment: ContentAlignment) GridDistribution { + let free: i64 = free_space; + if (free < 0) { free = 0; } + let result: GridDistribution = GridDistribution{ .leading = 0, .extra_gap = 0 }; + switch (alignment) { + .start => { } + .center => { result.leading = free / 2; } + .end => { result.leading = free; } + .space_between => { if (count > 1) { result.extra_gap = free / ((count - 1) as i64); } } + .space_around => { + if (count > 0) { result.extra_gap = free / (count as i64); result.leading = result.extra_gap / 2; } + } + .space_evenly => { + if (count > 0) { result.extra_gap = free / ((count + 1) as i64); result.leading = result.extra_gap; } + } + .stretch => { } + } + return result; +} + +fn grid_axis_item_size(minimum: i64, preferred: i64, maximum: i64, available: i64, + alignment: PlacementAlignment) i64 { + if (alignment == PlacementAlignment.stretch) { return clamp_i64(available, minimum, maximum); } + let upper: i64 = maximum; + if (available < upper) { upper = available; } + return clamp_i64(preferred, minimum, upper); +} + +fn grid_axis_item_offset(available: i64, extent: i64, alignment: PlacementAlignment) i64 { + if (alignment == PlacementAlignment.center) { return (available - extent) / 2; } + if (alignment == PlacementAlignment.end) { return available - extent; } + return 0; +} + +fn grid_track_hash(track: GridTrack) i64 { + let hash: i64 = grid_mix(17, track.kind as i64); + hash = grid_mix(hash, track.minimum); hash = grid_mix(hash, track.preferred); + hash = grid_mix(hash, track.maximum); hash = grid_mix(hash, track.fraction); + return hash; +} + +fn grid_input_hash(items: ArrayList[GridItem], columns: ArrayList[GridTrack], + rows: ArrayList[GridTrack], constraints: Constraints, style: GridStyle) i64 { + let hash: i64 = grid_mix(grid_mix(17, columns.len as i64), rows.len as i64); + hash = grid_mix(hash, constraints.min_width); hash = grid_mix(hash, constraints.max_width); + hash = grid_mix(hash, constraints.min_height); hash = grid_mix(hash, constraints.max_height); + hash = grid_mix(hash, style.column_gap); hash = grid_mix(hash, style.row_gap); + hash = grid_mix(hash, style.padding.top); hash = grid_mix(hash, style.padding.right); + hash = grid_mix(hash, style.padding.bottom); hash = grid_mix(hash, style.padding.left); + hash = grid_mix(hash, style.direction as i64); + hash = grid_mix(hash, style.horizontal_content as i64); + hash = grid_mix(hash, style.vertical_content as i64); + hash = grid_mix(hash, style.allow_overlap as i64); + let i: i32 = 0; + while (i < columns.len) { hash = grid_mix(hash, grid_track_hash(columns.data[i])); i = i + 1; } + i = 0; + while (i < rows.len) { hash = grid_mix(hash, grid_track_hash(rows.data[i])); i = i + 1; } + i = 0; + while (i < items.len) { + let item: GridItem = items.data[i]; + hash = grid_mix(hash, item.id); hash = grid_mix(hash, item.row as i64); + hash = grid_mix(hash, item.column as i64); hash = grid_mix(hash, item.row_span as i64); + hash = grid_mix(hash, item.column_span as i64); + hash = grid_mix(hash, intrinsic_metrics_hash(item.metrics)); + hash = grid_mix(hash, item.horizontal as i64); hash = grid_mix(hash, item.vertical as i64); + hash = grid_mix(hash, item.z_order); + i = i + 1; + } + return hash; +} + +fn grid_output_hash(result: GridResult) i64 { + let hash: i64 = grid_mix(grid_mix(17, result.container.width), result.container.height); + hash = grid_mix(hash, result.overflow_width); hash = grid_mix(hash, result.overflow_height); + hash = grid_mix(hash, result.overlap_cells as i64); hash = grid_mix(hash, result.error as i64); + let i: i32 = 0; + while (i < result.columns.len) { + hash = grid_mix(hash, result.columns.data[i].offset); + hash = grid_mix(hash, result.columns.data[i].extent); i = i + 1; + } + i = 0; + while (i < result.rows.len) { + hash = grid_mix(hash, result.rows.data[i].offset); + hash = grid_mix(hash, result.rows.data[i].extent); i = i + 1; + } + i = 0; + while (i < result.placements.len) { + let placement: GridPlacement = result.placements.data[i]; + hash = grid_mix(hash, placement.id); hash = grid_mix(hash, placement.row as i64); + hash = grid_mix(hash, placement.column as i64); + hash = grid_mix(hash, placement.frame.x); hash = grid_mix(hash, placement.frame.y); + hash = grid_mix(hash, placement.frame.width); hash = grid_mix(hash, placement.frame.height); + hash = grid_mix(hash, placement.baseline); hash = grid_mix(hash, placement.z_order); + i = i + 1; + } + return hash; +} + +fn grid_layout(items: ArrayList[GridItem], columns: ArrayList[GridTrack], rows: ArrayList[GridTrack], + raw_constraints: Constraints, style: GridStyle) GridResult { + if (items.len > 4096) { return grid_error_result(GridError.too_many_items, 0 - 1); } + if (columns.len <= 0 || rows.len <= 0 || columns.len > 128 || rows.len > 128) { + return grid_error_result(GridError.too_many_tracks, 0 - 1); + } + let constraints: Constraints = normalize_constraints(raw_constraints); + if (constraints.min_width > unbounded_extent() || constraints.max_width > unbounded_extent() || + constraints.min_height > unbounded_extent() || constraints.max_height > unbounded_extent()) { + return grid_error_result(GridError.invalid_constraints, 0 - 1); + } + if (grid_extent_valid(style.column_gap) == 0 || grid_extent_valid(style.row_gap) == 0 || + grid_insets_valid(style.padding) == 0 || + (style.allow_overlap != 0 && style.allow_overlap != 1)) { + return grid_error_result(GridError.invalid_style, 0 - 1); + } + let i: i32 = 0; + while (i < columns.len) { + if (grid_track_valid(columns.data[i]) == 0) { return grid_error_result(GridError.invalid_track, i); } + i = i + 1; + } + i = 0; + while (i < rows.len) { + if (grid_track_valid(rows.data[i]) == 0) { return grid_error_result(GridError.invalid_track, columns.len + i); } + i = i + 1; + } + i = 0; + while (i < items.len) { + if (grid_item_valid(items.data[i], rows.len, columns.len) == 0) { + return grid_error_result(GridError.invalid_item, i); + } + let j: i32 = 0; + while (j < i) { + if (items.data[j].id == items.data[i].id) { return grid_error_result(GridError.duplicate_id, i); } + j = j + 1; + } + i = i + 1; + } + + let occupancy: ArrayList[i32] = make[i32](rows.len * columns.len); + i = 0; + while (i < rows.len * columns.len) { push[i32](&occupancy, 0); i = i + 1; } + let cells: ArrayList[GridCell] = make[GridCell](items.len); + let overlap_cells: i32 = 0; + i = 0; + while (i < items.len) { + let item: GridItem = items.data[i]; + let row: i32 = item.row; + let column: i32 = item.column; + if (row < 0) { + row = 0 - 1; + let candidate_row: i32 = 0; + while (candidate_row + item.row_span <= rows.len && row < 0) { + let candidate_column: i32 = 0; + while (candidate_column + item.column_span <= columns.len && row < 0) { + if (grid_cells_free(occupancy, columns.len, candidate_row, candidate_column, + item.row_span, item.column_span) == 1) { + row = candidate_row; column = candidate_column; + } + candidate_column = candidate_column + 1; + } + candidate_row = candidate_row + 1; + } + if (row < 0) { + free[i32](&occupancy); free[GridCell](&cells); + return grid_error_result(GridError.no_space, i); + } + } else if (grid_cells_free(occupancy, columns.len, row, column, + item.row_span, item.column_span) == 0 && style.allow_overlap == 0) { + free[i32](&occupancy); free[GridCell](&cells); + return grid_error_result(GridError.collision, i); + } + overlap_cells = overlap_cells + grid_mark_cells(&occupancy, columns.len, row, column, + item.row_span, item.column_span, i + 1); + push[GridCell](&cells, GridCell{ .row = row, .column = column }); + i = i + 1; + } + + let column_sizes: ArrayList[i64] = make[i64](columns.len); + let row_sizes: ArrayList[i64] = make[i64](rows.len); + let error_index: i32 = grid_axis_prepare(items, cells, columns, &column_sizes, style.column_gap, 1); + if (error_index >= 0) { + free[i32](&occupancy); free[GridCell](&cells); free[i64](&column_sizes); free[i64](&row_sizes); + return grid_error_result(GridError.arithmetic_overflow, error_index); + } + error_index = grid_axis_prepare(items, cells, rows, &row_sizes, style.row_gap, 0); + if (error_index >= 0) { + free[i32](&occupancy); free[GridCell](&cells); free[i64](&column_sizes); free[i64](&row_sizes); + return grid_error_result(GridError.arithmetic_overflow, error_index); + } + + let horizontal_padding: i64 = grid_checked_add(style.padding.left, style.padding.right); + let vertical_padding: i64 = grid_checked_add(style.padding.top, style.padding.bottom); + let column_gaps: i64 = 0; + i = 1; + while (i < columns.len) { column_gaps = grid_checked_add(column_gaps, style.column_gap); i = i + 1; } + let row_gaps: i64 = 0; + i = 1; + while (i < rows.len) { row_gaps = grid_checked_add(row_gaps, style.row_gap); i = i + 1; } + let desired_width: i64 = grid_checked_add(grid_checked_add(horizontal_padding, column_gaps), grid_sizes_sum(column_sizes)); + let desired_height: i64 = grid_checked_add(grid_checked_add(vertical_padding, row_gaps), grid_sizes_sum(row_sizes)); + if (horizontal_padding < 0 || vertical_padding < 0 || column_gaps < 0 || row_gaps < 0 || + desired_width < 0 || desired_height < 0) { + free[i32](&occupancy); free[GridCell](&cells); free[i64](&column_sizes); free[i64](&row_sizes); + return grid_error_result(GridError.arithmetic_overflow, 0 - 1); + } + let container: Size = size( + clamp_i64(desired_width, constraints.min_width, constraints.max_width), + clamp_i64(desired_height, constraints.min_height, constraints.max_height) + ); + let available_columns: i64 = container.width - horizontal_padding - column_gaps; + if (available_columns < 0) { available_columns = 0; } + let available_rows: i64 = container.height - vertical_padding - row_gaps; + if (available_rows < 0) { available_rows = 0; } + let used_columns: i64 = grid_adjust_axis(columns, &column_sizes, available_columns, style.horizontal_content); + let used_rows: i64 = grid_adjust_axis(rows, &row_sizes, available_rows, style.vertical_content); + if (used_columns < 0 || used_rows < 0) { + free[i32](&occupancy); free[GridCell](&cells); free[i64](&column_sizes); free[i64](&row_sizes); + return grid_error_result(GridError.arithmetic_overflow, 0 - 1); + } + let column_distribution: GridDistribution = grid_distribution( + available_columns - used_columns, columns.len, style.horizontal_content); + let row_distribution: GridDistribution = grid_distribution( + available_rows - used_rows, rows.len, style.vertical_content); + + let column_offsets: ArrayList[i64] = make[i64](columns.len); + let row_offsets: ArrayList[i64] = make[i64](rows.len); + let cursor: i64 = column_distribution.leading; + i = 0; + while (i < columns.len) { + push[i64](&column_offsets, cursor); + cursor = cursor + column_sizes.data[i]; + if (i + 1 < columns.len) { cursor = cursor + style.column_gap + column_distribution.extra_gap; } + i = i + 1; + } + cursor = row_distribution.leading; + i = 0; + while (i < rows.len) { + push[i64](&row_offsets, cursor); + cursor = cursor + row_sizes.data[i]; + if (i + 1 < rows.len) { cursor = cursor + style.row_gap + row_distribution.extra_gap; } + i = i + 1; + } + + let column_output: ArrayList[GridTrackPlacement] = make[GridTrackPlacement](columns.len); + i = 0; + while (i < columns.len) { + let offset: i64 = style.padding.left + column_offsets.data[i]; + if (style.direction == LayoutDirection.rtl) { + offset = container.width - style.padding.right - column_offsets.data[i] - column_sizes.data[i]; + } + push[GridTrackPlacement](&column_output, GridTrackPlacement{ .offset = offset, .extent = column_sizes.data[i] }); + i = i + 1; + } + let row_output: ArrayList[GridTrackPlacement] = make[GridTrackPlacement](rows.len); + i = 0; + while (i < rows.len) { + push[GridTrackPlacement](&row_output, GridTrackPlacement{ + .offset = style.padding.top + row_offsets.data[i], .extent = row_sizes.data[i], + }); + i = i + 1; + } + + let row_baselines: ArrayList[i64] = make[i64](rows.len); + i = 0; + while (i < rows.len) { push[i64](&row_baselines, 0); i = i + 1; } + i = 0; + while (i < items.len) { + if (items.data[i].vertical == PlacementAlignment.baseline) { + let baseline: i64 = items.data[i].metrics.baseline; + let row_index: i32 = cells.data[i].row; + if (baseline > row_baselines.data[row_index]) { row_baselines.data[row_index] = baseline; } + } + i = i + 1; + } + + let placements: ArrayList[GridPlacement] = make[GridPlacement](items.len); + let overflow_width: i64 = desired_width - container.width; + if (overflow_width < 0) { overflow_width = 0; } + let overflow_height: i64 = desired_height - container.height; + if (overflow_height < 0) { overflow_height = 0; } + i = 0; + while (i < items.len) { + let item: GridItem = items.data[i]; + let cell: GridCell = cells.data[i]; + let cell_width: i64 = grid_span_extent(column_sizes, cell.column, item.column_span, + style.column_gap + column_distribution.extra_gap); + let cell_height: i64 = grid_span_extent(row_sizes, cell.row, item.row_span, + style.row_gap + row_distribution.extra_gap); + let width: i64 = grid_axis_item_size(item.metrics.minimum.width, item.metrics.preferred.width, + item.metrics.maximum.width, cell_width, item.horizontal); + let height: i64 = grid_axis_item_size(item.metrics.minimum.height, item.metrics.preferred.height, + item.metrics.maximum.height, cell_height, item.vertical); + let x_offset: i64 = grid_axis_item_offset(cell_width, width, item.horizontal); + let y_offset: i64 = grid_axis_item_offset(cell_height, height, item.vertical); + if (item.vertical == PlacementAlignment.baseline) { + y_offset = row_baselines.data[cell.row] - item.metrics.baseline; + } + let logical_x: i64 = column_offsets.data[cell.column] + x_offset; + let frame_x: i64 = style.padding.left + logical_x; + if (style.direction == LayoutDirection.rtl) { + frame_x = container.width - style.padding.right - logical_x - width; + } + let frame: Rect = rect(frame_x, style.padding.top + row_offsets.data[cell.row] + y_offset, + width, height); + let item_overflow_width: i64 = width - cell_width; + if (item_overflow_width > overflow_width) { overflow_width = item_overflow_width; } + let item_overflow_height: i64 = height - cell_height; + if (item_overflow_height > overflow_height) { overflow_height = item_overflow_height; } + push[GridPlacement](&placements, GridPlacement{ + .id = item.id, .row = cell.row, .column = cell.column, + .row_span = item.row_span, .column_span = item.column_span, + .frame = frame, .baseline = frame.y + clamp_i64(item.metrics.baseline, 0, frame.height), + .z_order = item.z_order, + }); + i = i + 1; + } + + let result: GridResult = GridResult{ + .placements = placements, .columns = column_output, .rows = row_output, + .container = container, .overflow_width = overflow_width, .overflow_height = overflow_height, + .overlap_cells = overlap_cells, .layout_hash = 0, + .error = GridError.none, .error_index = 0 - 1, + }; + result.layout_hash = grid_mix(grid_input_hash(items, columns, rows, constraints, style), grid_output_hash(result)); + free[i32](&occupancy); free[GridCell](&cells); free[i64](&column_sizes); free[i64](&row_sizes); + free[i64](&column_offsets); free[i64](&row_offsets); free[i64](&row_baselines); + return result; +} diff --git a/src/layout/overlay.zag b/src/layout/overlay.zag new file mode 100644 index 0000000..1c7f684 --- /dev/null +++ b/src/layout/overlay.zag @@ -0,0 +1,322 @@ +@import("std:list") +@import("../core/geometry.zag") +@import("common.zag") +@import("measure.zag") + +enum OverlayError { + none, + too_many_items, + invalid_constraints, + invalid_style, + invalid_item, + duplicate_id, + arithmetic_overflow, +} + +struct OverlayItem { + id: i64, + metrics: IntrinsicMetrics, + horizontal: PlacementAlignment, + vertical: PlacementAlignment, + insets: Insets, + z_order: i64, +} + +struct OverlayStyle { + padding: Insets, + direction: LayoutDirection, +} + +struct OverlayPlacement { + id: i64, + frame: Rect, + baseline: i64, + z_order: i64, +} + +struct OverlayResult { + placements: ArrayList[OverlayPlacement], + container: Size, + overflow_width: i64, + overflow_height: i64, + layout_hash: i64, + error: OverlayError, + error_index: i32, +} + +fn overlay_item(id: i64, metrics: IntrinsicMetrics) OverlayItem { + return OverlayItem{ + .id = id, + .metrics = metrics, + .horizontal = PlacementAlignment.start, + .vertical = PlacementAlignment.start, + .insets = insets_all(0), + .z_order = 0, + }; +} + +fn overlay_style(padding: Insets, direction: LayoutDirection) OverlayStyle { + return OverlayStyle{ .padding = padding, .direction = direction }; +} + +fn overlay_item_limit() i32 { return 4096; } + +fn overlay_free(result: *OverlayResult) void { + free[OverlayPlacement](&result.*.placements); +} + +fn overlay_mix(hash: i64, value: i64) i64 { + return (hash * 1099511628211) ^ value; +} + +fn overlay_error_result(error: OverlayError, index: i32) OverlayResult { + let result: OverlayResult = OverlayResult{ + .placements = make[OverlayPlacement](1), + .container = size(0, 0), + .overflow_width = 0, + .overflow_height = 0, + .layout_hash = 17, + .error = error, + .error_index = index, + }; + result.layout_hash = overlay_mix(result.layout_hash, error as i64); + result.layout_hash = overlay_mix(result.layout_hash, index as i64); + return result; +} + +fn overlay_extent_valid(value: i64) i32 { + return (value >= 0 && value <= unbounded_extent() / 4) as i32; +} + +fn overlay_insets_valid(value: Insets) i32 { + return (overlay_extent_valid(value.top) == 1 && overlay_extent_valid(value.right) == 1 && + overlay_extent_valid(value.bottom) == 1 && overlay_extent_valid(value.left) == 1) as i32; +} + +fn overlay_item_valid(item: OverlayItem) i32 { + if (item.id <= 0 || intrinsic_metric_valid(item.metrics) == 0 || + overlay_insets_valid(item.insets) == 0) { return 0; } + if (item.horizontal == PlacementAlignment.baseline || + item.vertical == PlacementAlignment.baseline) { return 0; } + return 1; +} + +fn overlay_checked_add(a: i64, b: i64) i64 { + if (a < 0 || b < 0 || a > unbounded_extent() - b) { return 0 - 1; } + return a + b; +} + +fn overlay_axis_size( + minimum: i64, + preferred: i64, + maximum: i64, + available: i64, + alignment: PlacementAlignment, +) i64 { + if (alignment == PlacementAlignment.stretch) { + return clamp_i64(available, minimum, maximum); + } + let upper: i64 = maximum; + if (available < upper) { upper = available; } + return clamp_i64(preferred, minimum, upper); +} + +fn overlay_axis_offset( + available: i64, + extent: i64, + alignment: PlacementAlignment, + reverse: i32, +) i64 { + let remaining: i64 = available - extent; + if (alignment == PlacementAlignment.center) { return remaining / 2; } + if (alignment == PlacementAlignment.end) { + if (reverse != 0) { return 0; } + return remaining; + } + if (alignment == PlacementAlignment.start) { + if (reverse != 0) { return remaining; } + return 0; + } + return 0; +} + +fn overlay_hash(result: OverlayResult) i64 { + let hash: i64 = overlay_mix(overlay_mix(17, result.container.width), result.container.height); + hash = overlay_mix(hash, result.overflow_width); + hash = overlay_mix(hash, result.overflow_height); + hash = overlay_mix(hash, result.error as i64); + hash = overlay_mix(hash, result.error_index as i64); + hash = overlay_mix(hash, result.placements.len as i64); + let i: i32 = 0; + while (i < result.placements.len) { + let placement: OverlayPlacement = result.placements.data[i]; + hash = overlay_mix(hash, placement.id); + hash = overlay_mix(hash, placement.frame.x); + hash = overlay_mix(hash, placement.frame.y); + hash = overlay_mix(hash, placement.frame.width); + hash = overlay_mix(hash, placement.frame.height); + hash = overlay_mix(hash, placement.baseline); + hash = overlay_mix(hash, placement.z_order); + i = i + 1; + } + return hash; +} + +fn overlay_input_hash( + items: ArrayList[OverlayItem], + constraints: Constraints, + style: OverlayStyle, +) i64 { + let hash: i64 = overlay_mix(17, items.len as i64); + hash = overlay_mix(hash, constraints.min_width); + hash = overlay_mix(hash, constraints.max_width); + hash = overlay_mix(hash, constraints.min_height); + hash = overlay_mix(hash, constraints.max_height); + hash = overlay_mix(hash, style.padding.top); + hash = overlay_mix(hash, style.padding.right); + hash = overlay_mix(hash, style.padding.bottom); + hash = overlay_mix(hash, style.padding.left); + hash = overlay_mix(hash, style.direction as i64); + let i: i32 = 0; + while (i < items.len) { + let item: OverlayItem = items.data[i]; + hash = overlay_mix(hash, item.id); + hash = overlay_mix(hash, intrinsic_metrics_hash(item.metrics)); + hash = overlay_mix(hash, item.horizontal as i64); + hash = overlay_mix(hash, item.vertical as i64); + hash = overlay_mix(hash, item.insets.top); + hash = overlay_mix(hash, item.insets.right); + hash = overlay_mix(hash, item.insets.bottom); + hash = overlay_mix(hash, item.insets.left); + hash = overlay_mix(hash, item.z_order); + i = i + 1; + } + return hash; +} + +fn overlay_layout( + items: ArrayList[OverlayItem], + raw_constraints: Constraints, + style: OverlayStyle, +) OverlayResult { + if (items.len > overlay_item_limit()) { + return overlay_error_result(OverlayError.too_many_items, 0 - 1); + } + let constraints: Constraints = normalize_constraints(raw_constraints); + if (constraints.min_width > unbounded_extent() || constraints.max_width > unbounded_extent() || + constraints.min_height > unbounded_extent() || constraints.max_height > unbounded_extent()) { + return overlay_error_result(OverlayError.invalid_constraints, 0 - 1); + } + if (overlay_insets_valid(style.padding) == 0) { + return overlay_error_result(OverlayError.invalid_style, 0 - 1); + } + let i: i32 = 0; + while (i < items.len) { + if (overlay_item_valid(items.data[i]) == 0) { + return overlay_error_result(OverlayError.invalid_item, i); + } + let j: i32 = 0; + while (j < i) { + if (items.data[j].id == items.data[i].id) { + return overlay_error_result(OverlayError.duplicate_id, i); + } + j = j + 1; + } + i = i + 1; + } + + let horizontal_padding: i64 = overlay_checked_add(style.padding.left, style.padding.right); + let vertical_padding: i64 = overlay_checked_add(style.padding.top, style.padding.bottom); + if (horizontal_padding < 0 || vertical_padding < 0) { + return overlay_error_result(OverlayError.arithmetic_overflow, 0 - 1); + } + let desired_width: i64 = horizontal_padding; + let desired_height: i64 = vertical_padding; + i = 0; + while (i < items.len) { + let item: OverlayItem = items.data[i]; + let item_horizontal: i64 = overlay_checked_add(item.insets.left, item.insets.right); + let item_vertical: i64 = overlay_checked_add(item.insets.top, item.insets.bottom); + let width: i64 = overlay_checked_add(item.metrics.preferred.width, item_horizontal); + let height: i64 = overlay_checked_add(item.metrics.preferred.height, item_vertical); + if (width < 0 || height < 0) { + return overlay_error_result(OverlayError.arithmetic_overflow, i); + } + let outer_width: i64 = overlay_checked_add(horizontal_padding, width); + let outer_height: i64 = overlay_checked_add(vertical_padding, height); + if (outer_width < 0 || outer_height < 0) { + return overlay_error_result(OverlayError.arithmetic_overflow, i); + } + if (outer_width > desired_width) { desired_width = outer_width; } + if (outer_height > desired_height) { desired_height = outer_height; } + i = i + 1; + } + let container: Size = size( + clamp_i64(desired_width, constraints.min_width, constraints.max_width), + clamp_i64(desired_height, constraints.min_height, constraints.max_height) + ); + let content_width: i64 = container.width - horizontal_padding; + let content_height: i64 = container.height - vertical_padding; + if (content_width < 0) { content_width = 0; } + if (content_height < 0) { content_height = 0; } + + let placements: ArrayList[OverlayPlacement] = make[OverlayPlacement](items.len); + let overflow_width: i64 = horizontal_padding - container.width; + if (overflow_width < 0) { overflow_width = 0; } + let overflow_height: i64 = vertical_padding - container.height; + if (overflow_height < 0) { overflow_height = 0; } + i = 0; + while (i < items.len) { + let item: OverlayItem = items.data[i]; + let item_horizontal: i64 = item.insets.left + item.insets.right; + let item_vertical: i64 = item.insets.top + item.insets.bottom; + let available_width: i64 = content_width - item_horizontal; + let available_height: i64 = content_height - item_vertical; + if (available_width < 0) { available_width = 0; } + if (available_height < 0) { available_height = 0; } + let width: i64 = overlay_axis_size( + item.metrics.minimum.width, item.metrics.preferred.width, + item.metrics.maximum.width, available_width, item.horizontal + ); + let height: i64 = overlay_axis_size( + item.metrics.minimum.height, item.metrics.preferred.height, + item.metrics.maximum.height, available_height, item.vertical + ); + let x_offset: i64 = overlay_axis_offset( + available_width, width, item.horizontal, + (style.direction == LayoutDirection.rtl) as i32 + ); + let y_offset: i64 = 0; + if (item.vertical == PlacementAlignment.center) { y_offset = (available_height - height) / 2; } + else if (item.vertical == PlacementAlignment.end) { y_offset = available_height - height; } + let frame: Rect = rect( + style.padding.left + item.insets.left + x_offset, + style.padding.top + item.insets.top + y_offset, + width, + height + ); + let item_overflow_width: i64 = width - available_width; + if (item_overflow_width > overflow_width) { overflow_width = item_overflow_width; } + let item_overflow_height: i64 = height - available_height; + if (item_overflow_height > overflow_height) { overflow_height = item_overflow_height; } + push[OverlayPlacement](&placements, OverlayPlacement{ + .id = item.id, + .frame = frame, + .baseline = frame.y + clamp_i64(item.metrics.baseline, 0, frame.height), + .z_order = item.z_order, + }); + i = i + 1; + } + + let result: OverlayResult = OverlayResult{ + .placements = placements, + .container = container, + .overflow_width = overflow_width, + .overflow_height = overflow_height, + .layout_hash = 0, + .error = OverlayError.none, + .error_index = 0 - 1, + }; + result.layout_hash = overlay_mix(overlay_input_hash(items, constraints, style), overlay_hash(result)); + return result; +} diff --git a/src/replay/replay.zag b/src/replay/replay.zag index 8d9da04..6f3c851 100644 --- a/src/replay/replay.zag +++ b/src/replay/replay.zag @@ -3,6 +3,8 @@ @import("../input/router.zag") @import("../layout/flex.zag") @import("../layout/flex_wrap.zag") +@import("../layout/grid.zag") +@import("../layout/overlay.zag") @import("../semantics/semantics.zag") @import("../render/cpu_raster.zag") @import("../motion/motion.zag") @@ -414,8 +416,55 @@ fn replay_build_scene(state_value: i64, backend_generation: i64) ReplayResult { semantics_tree_free(&semantics); return replay_result_error(ReplayError.scene_error, 0 - 1, 0); } - result.layout_hash = layout.layout_hash; + let overlay_items: ArrayList[OverlayItem] = make[OverlayItem](2); + let overlay_first: OverlayItem = overlay_item(101, first_metrics); + overlay_first.horizontal = PlacementAlignment.start; + overlay_first.vertical = PlacementAlignment.start; + push[OverlayItem](&overlay_items, overlay_first); + let overlay_second: OverlayItem = overlay_item(102, second_metrics); + overlay_second.horizontal = PlacementAlignment.end; + overlay_second.vertical = PlacementAlignment.end; + push[OverlayItem](&overlay_items, overlay_second); + let overlay_layout_result: OverlayResult = overlay_layout( + overlay_items, tight_constraints(8 * unit_scale(), 6 * unit_scale()), + overlay_style(insets_all(0), LayoutDirection.ltr) + ); + free[OverlayItem](&overlay_items); + if (overlay_layout_result.error != OverlayError.none) { + overlay_free(&overlay_layout_result); + flex_wrap_free(&layout); + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } + + let grid_columns: ArrayList[GridTrack] = make[GridTrack](2); + push[GridTrack](&grid_columns, grid_fraction(1, unit_scale(), unbounded_extent())); + push[GridTrack](&grid_columns, grid_fraction(1, unit_scale(), unbounded_extent())); + let grid_rows: ArrayList[GridTrack] = make[GridTrack](1); + push[GridTrack](&grid_rows, grid_intrinsic(unit_scale(), 4 * unit_scale(), 6 * unit_scale())); + let grid_items: ArrayList[GridItem] = make[GridItem](2); + push[GridItem](&grid_items, grid_item_at(101, 0, 0, first_metrics)); + push[GridItem](&grid_items, grid_item_at(102, 0, 1, second_metrics)); + let grid_layout_result: GridResult = grid_layout( + grid_items, grid_columns, grid_rows, + tight_constraints(8 * unit_scale(), 6 * unit_scale()), + grid_style(unit_scale(), 0, insets_all(0)) + ); + free[GridItem](&grid_items); free[GridTrack](&grid_columns); free[GridTrack](&grid_rows); + if (grid_layout_result.error != GridError.none) { + grid_free(&grid_layout_result); + overlay_free(&overlay_layout_result); + flex_wrap_free(&layout); + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } + result.layout_hash = display_mix( + display_mix(layout.layout_hash, overlay_layout_result.layout_hash), + grid_layout_result.layout_hash + ); result.layout_lines = layout.lines.len; + grid_free(&grid_layout_result); + overlay_free(&overlay_layout_result); let list: DisplayList = display_list_make(); let first_op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(101)); diff --git a/tests/grid_contract.zag b/tests/grid_contract.zag new file mode 100644 index 0000000..34cd9a7 --- /dev/null +++ b/tests/grid_contract.zag @@ -0,0 +1,208 @@ +@import("std:list") +@import("../src/layout/grid.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn metrics(min_width: i64, min_height: i64, width: i64, height: i64, + max_width: i64, max_height: i64, baseline: i64) IntrinsicMetrics { + return intrinsic_metrics(min_width, min_height, width, height, max_width, max_height, baseline); +} + +fn basic_grid_contract(state: *TestState) void { + let columns: ArrayList[GridTrack] = make[GridTrack](3); + push[GridTrack](&columns, grid_fixed(4)); + push[GridTrack](&columns, grid_fraction(1, 2, 20)); + push[GridTrack](&columns, grid_fraction(2, 2, 20)); + let rows: ArrayList[GridTrack] = make[GridTrack](2); + push[GridTrack](&rows, grid_intrinsic(2, 4, 10)); + push[GridTrack](&rows, grid_fixed(3)); + let items: ArrayList[GridItem] = make[GridItem](3); + push[GridItem](&items, grid_item_at(1, 0, 0, metrics(2, 2, 4, 2, 8, 8, 1))); + let automatic: GridItem = grid_item(2, metrics(1, 1, 2, 2, 8, 8, 1)); + push[GridItem](&items, automatic); + let spanning: GridItem = grid_item_at(3, 1, 1, metrics(4, 2, 10, 3, 30, 6, 2)); + spanning.column_span = 2; + spanning.z_order = 7; + push[GridItem](&items, spanning); + let style: GridStyle = grid_style(1, 1, insets_all(1)); + style.vertical_content = ContentAlignment.space_between; + let result: GridResult = grid_layout(items, columns, rows, tight_constraints(30, 12), style); + expect(state, result.error == GridError.none && result.columns.len == 3 && result.rows.len == 2 && + result.columns.data[0].extent == 4 && result.columns.data[1].extent == 8 && + result.columns.data[2].extent == 14, + "grid resolves fixed and weighted fraction tracks against exact available width"); + expect(state, result.placements.data[1].row == 0 && result.placements.data[1].column == 1 && + result.placements.data[1].frame.x == 6, + "grid auto placement is deterministic row-major and preserves input identity"); + expect(state, result.placements.data[2].frame.x == 6 && result.placements.data[2].frame.y == 8 && + result.placements.data[2].frame.width == 23 && result.placements.data[2].frame.height == 3 && + result.placements.data[2].z_order == 7, + "grid spans include interior gaps and retained z-order without geometry ambiguity"); + expect(state, result.overflow_width == 0 && result.overflow_height == 0 && result.layout_hash != 0, + "grid exposes clean per-axis overflow and stable nonzero layout identity"); + let repeated: GridResult = grid_layout(items, columns, rows, tight_constraints(30, 12), style); + expect(state, repeated.layout_hash == result.layout_hash, + "identical grid inputs produce identical track and placement identity"); + grid_free(&repeated); grid_free(&result); + free[GridItem](&items); free[GridTrack](&columns); free[GridTrack](&rows); +} + +fn span_intrinsic_and_rtl_contract(state: *TestState) void { + let columns: ArrayList[GridTrack] = make[GridTrack](2); + push[GridTrack](&columns, grid_intrinsic(2, 2, 20)); + push[GridTrack](&columns, grid_intrinsic(2, 2, 20)); + let rows: ArrayList[GridTrack] = make[GridTrack](1); + push[GridTrack](&rows, grid_intrinsic(2, 3, 10)); + let items: ArrayList[GridItem] = make[GridItem](1); + let span: GridItem = grid_item_at(1, 0, 0, metrics(8, 2, 15, 3, 30, 8, 2)); + span.column_span = 2; + span.horizontal = PlacementAlignment.start; + span.vertical = PlacementAlignment.start; + push[GridItem](&items, span); + let loose: Constraints = Constraints{ + .min_width = 0, .max_width = unbounded_extent(), + .min_height = 0, .max_height = unbounded_extent(), + }; + let ltr_style: GridStyle = grid_style(1, 1, insets_all(1)); + let ltr: GridResult = grid_layout(items, columns, rows, loose, ltr_style); + expect(state, ltr.container.width == 17 && ltr.container.height == 5 && + ltr.columns.data[0].extent + ltr.columns.data[1].extent + 1 == 15, + "spanning intrinsic content grows eligible tracks and determines an unbounded container"); + let rtl_style: GridStyle = ltr_style; + rtl_style.direction = LayoutDirection.rtl; + let rtl: GridResult = grid_layout(items, columns, rows, loose, rtl_style); + expect(state, rtl.columns.data[0].offset > rtl.columns.data[1].offset && + rtl.placements.data[0].frame.x == 1 && rtl.layout_hash != ltr.layout_hash, + "RTL mirrors logical grid columns and records direction in deterministic identity"); + grid_free(&rtl); grid_free(<r); + free[GridItem](&items); free[GridTrack](&columns); free[GridTrack](&rows); +} + +fn baseline_contract(state: *TestState) void { + let columns: ArrayList[GridTrack] = make[GridTrack](2); + push[GridTrack](&columns, grid_fixed(10)); push[GridTrack](&columns, grid_fixed(10)); + let rows: ArrayList[GridTrack] = make[GridTrack](1); + push[GridTrack](&rows, grid_intrinsic(5, 6, 12)); + let items: ArrayList[GridItem] = make[GridItem](2); + let first: GridItem = grid_item_at(1, 0, 0, metrics(2, 2, 4, 4, 8, 8, 3)); + first.horizontal = PlacementAlignment.center; first.vertical = PlacementAlignment.baseline; + push[GridItem](&items, first); + let second: GridItem = grid_item_at(2, 0, 1, metrics(2, 2, 4, 5, 8, 8, 1)); + second.horizontal = PlacementAlignment.center; second.vertical = PlacementAlignment.baseline; + push[GridItem](&items, second); + let result: GridResult = grid_layout(items, columns, rows, tight_constraints(23, 8), + grid_style(1, 0, insets_all(1))); + expect(state, result.placements.data[0].baseline == result.placements.data[1].baseline && + result.placements.data[0].frame.y == 1 && result.placements.data[1].frame.y == 3, + "single-row baseline alignment preserves typography across unequal glyph boxes"); + grid_free(&result); free[GridItem](&items); free[GridTrack](&columns); free[GridTrack](&rows); +} + +fn collision_and_failure_contract(state: *TestState) void { + let columns: ArrayList[GridTrack] = make[GridTrack](1); push[GridTrack](&columns, grid_fixed(8)); + let rows: ArrayList[GridTrack] = make[GridTrack](1); push[GridTrack](&rows, grid_fixed(8)); + let items: ArrayList[GridItem] = make[GridItem](2); + push[GridItem](&items, grid_item_at(1, 0, 0, metrics(1, 1, 2, 2, 4, 4, 1))); + push[GridItem](&items, grid_item_at(2, 0, 0, metrics(1, 1, 2, 2, 4, 4, 1))); + let style: GridStyle = grid_style(0, 0, insets_all(0)); + let collision: GridResult = grid_layout(items, columns, rows, tight_constraints(8, 8), style); + expect(state, collision.error == GridError.collision && collision.error_index == 1 && + collision.placements.len == 0, + "explicit grid collisions fail at the exact item by default"); + grid_free(&collision); + style.allow_overlap = 1; + let overlap: GridResult = grid_layout(items, columns, rows, tight_constraints(8, 8), style); + expect(state, overlap.error == GridError.none && overlap.overlap_cells == 1 && + overlap.placements.len == 2, + "explicit overlap is opt-in and reports every occupied collision cell"); + grid_free(&overlap); + items.data[1].id = 1; + let duplicate: GridResult = grid_layout(items, columns, rows, tight_constraints(8, 8), style); + expect(state, duplicate.error == GridError.duplicate_id && duplicate.error_index == 1, + "duplicate grid IDs fail before placement"); + grid_free(&duplicate); + items.data[1].id = 2; items.data[0].row = 0 - 1; items.data[0].column = 0 - 1; + items.data[1].row = 0 - 1; items.data[1].column = 0 - 1; style.allow_overlap = 0; + let full: GridResult = grid_layout(items, columns, rows, tight_constraints(8, 8), style); + expect(state, full.error == GridError.no_space && full.error_index == 1, + "auto placement fails closed when no unoccupied span remains"); + grid_free(&full); + items.data[1].row = 0; items.data[1].column = 0 - 1; + let partial: GridResult = grid_layout(items, columns, rows, tight_constraints(8, 8), style); + expect(state, partial.error == GridError.invalid_item && partial.error_index == 1, + "partial auto coordinates are rejected instead of guessed"); + grid_free(&partial); + free[GridItem](&items); free[GridTrack](&columns); free[GridTrack](&rows); + + let bad_columns: ArrayList[GridTrack] = make[GridTrack](1); + push[GridTrack](&bad_columns, grid_fraction(0, 1, 4)); + let valid_rows: ArrayList[GridTrack] = make[GridTrack](1); push[GridTrack](&valid_rows, grid_fixed(2)); + let empty: ArrayList[GridItem] = make[GridItem](1); + let bad_track: GridResult = grid_layout(empty, bad_columns, valid_rows, tight_constraints(4, 2), + grid_style(0, 0, insets_all(0))); + expect(state, bad_track.error == GridError.invalid_track && bad_track.error_index == 0, + "zero-weight fraction tracks fail typed validation"); + grid_free(&bad_track); free[GridItem](&empty); free[GridTrack](&bad_columns); free[GridTrack](&valid_rows); +} + +fn overflow_and_property_contract(state: *TestState) void { + let fixed_columns: ArrayList[GridTrack] = make[GridTrack](2); + push[GridTrack](&fixed_columns, grid_fixed(8)); push[GridTrack](&fixed_columns, grid_fixed(8)); + let fixed_rows: ArrayList[GridTrack] = make[GridTrack](1); push[GridTrack](&fixed_rows, grid_fixed(4)); + let empty: ArrayList[GridItem] = make[GridItem](1); + let overflow: GridResult = grid_layout(empty, fixed_columns, fixed_rows, tight_constraints(12, 4), + grid_style(1, 0, insets_all(0))); + expect(state, overflow.error == GridError.none && overflow.overflow_width == 5 && + overflow.columns.data[1].offset == 9, + "unshrinkable fixed tracks remain exact and expose quantified overflow"); + grid_free(&overflow); free[GridItem](&empty); + free[GridTrack](&fixed_columns); free[GridTrack](&fixed_rows); + + let all_valid: i32 = 1; + let extent: i64 = 7; + while (extent <= 128) { + let columns: ArrayList[GridTrack] = make[GridTrack](2); + push[GridTrack](&columns, grid_fraction(1, 1, unbounded_extent())); + push[GridTrack](&columns, grid_fraction(3, 1, unbounded_extent())); + let rows: ArrayList[GridTrack] = make[GridTrack](1); push[GridTrack](&rows, grid_fraction(1, 1, unbounded_extent())); + let items: ArrayList[GridItem] = make[GridItem](2); + push[GridItem](&items, grid_item_at(1, 0, 0, metrics(1, 1, 1, 1, unbounded_extent(), unbounded_extent(), 1))); + push[GridItem](&items, grid_item_at(2, 0, 1, metrics(1, 1, 1, 1, unbounded_extent(), unbounded_extent(), 1))); + let result: GridResult = grid_layout(items, columns, rows, tight_constraints(extent, 7), + grid_style(1, 0, insets_all(1))); + if (result.error != GridError.none || result.columns.data[0].offset != 1 || + result.columns.data[1].offset != 2 + result.columns.data[0].extent || + result.columns.data[1].offset + result.columns.data[1].extent != extent - 1 || + result.overflow_width != 0) { all_valid = 0; } + grid_free(&result); free[GridItem](&items); free[GridTrack](&columns); free[GridTrack](&rows); + extent = extent + 1; + } + expect(state, all_valid == 1, + "122-extent fraction sweep fills exact padded bounds without drift or hidden overflow"); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + basic_grid_contract(&state); + span_intrinsic_and_rtl_contract(&state); + baseline_contract(&state); + collision_and_failure_contract(&state); + overflow_and_property_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Grid contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tests/overlay_contract.zag b/tests/overlay_contract.zag new file mode 100644 index 0000000..98cd10d --- /dev/null +++ b/tests/overlay_contract.zag @@ -0,0 +1,203 @@ +@import("std:list") +@import("../src/layout/overlay.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn metrics(min_width: i64, min_height: i64, width: i64, height: i64) IntrinsicMetrics { + return intrinsic_metrics(min_width, min_height, width, height, width * 2, height * 2, height - 1); +} + +fn alignment_contract(state: *TestState) void { + let items: ArrayList[OverlayItem] = make[OverlayItem](4); + let start: OverlayItem = overlay_item(1, metrics(2, 2, 4, 3)); + start.insets = insets_all(1); + start.z_order = 2; + push[OverlayItem](&items, start); + let centered: OverlayItem = overlay_item(2, metrics(2, 2, 4, 4)); + centered.horizontal = PlacementAlignment.center; + centered.vertical = PlacementAlignment.center; + centered.z_order = 1; + push[OverlayItem](&items, centered); + let ended: OverlayItem = overlay_item(3, metrics(2, 2, 3, 2)); + ended.horizontal = PlacementAlignment.end; + ended.vertical = PlacementAlignment.end; + push[OverlayItem](&items, ended); + let stretched: OverlayItem = overlay_item(4, intrinsic_metrics(3, 2, 4, 3, 20, 20, 2)); + stretched.horizontal = PlacementAlignment.stretch; + stretched.vertical = PlacementAlignment.stretch; + push[OverlayItem](&items, stretched); + + let result: OverlayResult = overlay_layout( + items, tight_constraints(20, 14), overlay_style(insets_all(2), LayoutDirection.ltr) + ); + expect(state, result.error == OverlayError.none && result.placements.len == 4 && + result.container.width == 20 && result.container.height == 14, + "overlay resolves every stable item inside exact constrained bounds"); + expect(state, result.placements.data[0].frame.x == 3 && result.placements.data[0].frame.y == 3 && + result.placements.data[0].z_order == 2, + "logical start composes outer padding item insets and retained z-order"); + expect(state, result.placements.data[1].frame.x == 8 && result.placements.data[1].frame.y == 5, + "overlay center alignment distributes odd-independent free space deterministically"); + expect(state, result.placements.data[2].frame.x == 15 && result.placements.data[2].frame.y == 10, + "overlay end alignment reaches the physical trailing content edges"); + expect(state, result.placements.data[3].frame.x == 2 && result.placements.data[3].frame.y == 2 && + result.placements.data[3].frame.width == 16 && result.placements.data[3].frame.height == 10, + "overlay stretch consumes available content without discarding intrinsic maxima"); + expect(state, result.placements.data[0].baseline == 5 && result.layout_hash != 0 && + result.overflow_width == 0 && result.overflow_height == 0, + "overlay retains baseline identity and explicit clean overflow truth"); + + let repeated: OverlayResult = overlay_layout( + items, tight_constraints(20, 14), overlay_style(insets_all(2), LayoutDirection.ltr) + ); + expect(state, repeated.layout_hash == result.layout_hash, + "identical overlay inputs produce identical layout identity"); + overlay_free(&repeated); + overlay_free(&result); + free[OverlayItem](&items); +} + +fn rtl_contract(state: *TestState) void { + let items: ArrayList[OverlayItem] = make[OverlayItem](2); + let start: OverlayItem = overlay_item(1, metrics(2, 2, 4, 3)); + push[OverlayItem](&items, start); + let end: OverlayItem = overlay_item(2, metrics(2, 2, 4, 3)); + end.horizontal = PlacementAlignment.end; + push[OverlayItem](&items, end); + let rtl: OverlayResult = overlay_layout( + items, tight_constraints(20, 10), overlay_style(insets_all(2), LayoutDirection.rtl) + ); + expect(state, rtl.placements.data[0].frame.x == 14 && rtl.placements.data[1].frame.x == 2, + "RTL swaps logical start and end while preserving physical padding truth"); + let ltr: OverlayResult = overlay_layout( + items, tight_constraints(20, 10), overlay_style(insets_all(2), LayoutDirection.ltr) + ); + expect(state, rtl.layout_hash != ltr.layout_hash, + "layout identity records direction even when other overlay inputs match"); + overlay_free(<r); overlay_free(&rtl); free[OverlayItem](&items); +} + +fn intrinsic_and_overflow_contract(state: *TestState) void { + let items: ArrayList[OverlayItem] = make[OverlayItem](1); + let item: OverlayItem = overlay_item(1, metrics(8, 6, 12, 10)); + item.insets = Insets{ .top = 2, .right = 3, .bottom = 4, .left = 5 }; + push[OverlayItem](&items, item); + let intrinsic: OverlayResult = overlay_layout( + items, + Constraints{ + .min_width = 0, .max_width = unbounded_extent(), + .min_height = 0, .max_height = unbounded_extent(), + }, + overlay_style(insets_all(1), LayoutDirection.ltr) + ); + expect(state, intrinsic.container.width == 22 && intrinsic.container.height == 18, + "unbounded overlay container derives exact preferred size from padding and insets"); + overlay_free(&intrinsic); + + let tight: OverlayResult = overlay_layout( + items, tight_constraints(10, 8), overlay_style(insets_all(1), LayoutDirection.ltr) + ); + expect(state, tight.placements.data[0].frame.width == 8 && tight.placements.data[0].frame.height == 6 && + tight.overflow_width == 8 && tight.overflow_height == 6, + "minimum intrinsic overlay size remains visible as per-axis overflow under pressure"); + overlay_free(&tight); free[OverlayItem](&items); + + let empty: ArrayList[OverlayItem] = make[OverlayItem](1); + let padding_overflow: OverlayResult = overlay_layout( + empty, tight_constraints(4, 4), overlay_style(insets_all(3), LayoutDirection.ltr) + ); + expect(state, padding_overflow.overflow_width == 2 && padding_overflow.overflow_height == 2, + "overlay reports outer padding overflow even without children"); + overlay_free(&padding_overflow); free[OverlayItem](&empty); +} + +fn failure_contract(state: *TestState) void { + let items: ArrayList[OverlayItem] = make[OverlayItem](2); + push[OverlayItem](&items, overlay_item(1, metrics(1, 1, 2, 2))); + push[OverlayItem](&items, overlay_item(1, metrics(1, 1, 2, 2))); + let duplicate: OverlayResult = overlay_layout( + items, tight_constraints(10, 10), overlay_style(insets_all(0), LayoutDirection.ltr) + ); + expect(state, duplicate.error == OverlayError.duplicate_id && duplicate.error_index == 1 && + duplicate.placements.len == 0 && duplicate.layout_hash != 0, + "duplicate overlay IDs fail at the exact item with owned empty output"); + overlay_free(&duplicate); + + items.data[1].id = 2; + items.data[1].horizontal = PlacementAlignment.baseline; + let bad_alignment: OverlayResult = overlay_layout( + items, tight_constraints(10, 10), overlay_style(insets_all(0), LayoutDirection.ltr) + ); + expect(state, bad_alignment.error == OverlayError.invalid_item && bad_alignment.error_index == 1, + "unsupported overlay baseline alignment fails instead of pretending to align"); + overlay_free(&bad_alignment); + + items.data[1].horizontal = PlacementAlignment.start; + items.data[1].metrics.preferred.width = 0; + let malformed: OverlayResult = overlay_layout( + items, tight_constraints(10, 10), overlay_style(insets_all(0), LayoutDirection.ltr) + ); + expect(state, malformed.error == OverlayError.invalid_item && malformed.error_index == 1, + "nonmonotonic overlay intrinsics fail before placement"); + overlay_free(&malformed); + + items.data[1].metrics = metrics(1, 1, 2, 2); + items.data[0].insets.left = unbounded_extent(); + let unsafe_result: OverlayResult = overlay_layout( + items, tight_constraints(10, 10), overlay_style(insets_all(0), LayoutDirection.ltr) + ); + expect(state, unsafe_result.error == OverlayError.invalid_item && unsafe_result.error_index == 0, + "unsafe overlay insets fail before bounded arithmetic"); + overlay_free(&unsafe_result); free[OverlayItem](&items); +} + +fn property_contract(state: *TestState) void { + let all_valid: i32 = 1; + let extent: i64 = 4; + while (extent <= 256) { + let items: ArrayList[OverlayItem] = make[OverlayItem](1); + let item: OverlayItem = overlay_item(1, intrinsic_metrics(1, 1, 2, 2, unbounded_extent(), unbounded_extent(), 1)); + item.horizontal = PlacementAlignment.stretch; + item.vertical = PlacementAlignment.stretch; + push[OverlayItem](&items, item); + let result: OverlayResult = overlay_layout( + items, tight_constraints(extent, extent), overlay_style(insets_all(1), LayoutDirection.ltr) + ); + let frame: Rect = result.placements.data[0].frame; + if (result.error != OverlayError.none || frame.x != 1 || frame.y != 1 || + frame.width != extent - 2 || frame.height != extent - 2 || + frame.x + frame.width != extent - 1 || frame.y + frame.height != extent - 1) { + all_valid = 0; + } + overlay_free(&result); free[OverlayItem](&items); + extent = extent + 1; + } + expect(state, all_valid == 1, + "253-extent overlay sweep preserves exact padding and stretch bounds"); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + alignment_contract(&state); + rtl_contract(&state); + intrinsic_and_overflow_contract(&state); + failure_contract(&state); + property_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Overlay contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index d46c795..7f2838a 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -20,6 +20,10 @@ cd "$root" "$tmp/flex-adaptive-contract" "$znc" tests/measure_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/measure-contract" "$tmp/measure-contract" +"$znc" tests/overlay_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/overlay-contract" +"$tmp/overlay-contract" +"$znc" tests/grid_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/grid-contract" +"$tmp/grid-contract" "$znc" tests/state_reconcile_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/state-reconcile-contract" "$tmp/state-reconcile-contract" "$znc" tests/semantics_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/semantics-contract" @@ -39,4 +43,4 @@ cd "$root" "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, semantics, Talkback, display lists, CPU raster, input, replay, and motion)\n' +printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, semantics, Talkback, display lists, CPU raster, input, replay, and motion)\n' From 9c036649b05a81386bd74bbe2f5360e99cca90e4 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 08:14:06 -0700 Subject: [PATCH 019/103] Add bounded scroll and virtual list layout --- README.md | 19 +- docs/architecture/flex.md | 1 - docs/architecture/measurement.md | 7 +- .../architecture/scroll-and-virtualization.md | 69 +++ docs/quality/release-gates.md | 6 + src/layout/scroll.zag | 240 ++++++++++ src/layout/virtual_list.zag | 439 ++++++++++++++++++ src/replay/replay.zag | 42 +- tests/replay_contract.zag | 2 +- tests/scroll_virtual_contract.zag | 243 ++++++++++ tools/test-headless.sh | 4 +- 11 files changed, 1060 insertions(+), 12 deletions(-) create mode 100644 docs/architecture/scroll-and-virtualization.md create mode 100644 src/layout/scroll.zag create mode 100644 src/layout/virtual_list.zag create mode 100644 tests/scroll_virtual_contract.zag diff --git a/README.md b/README.md index ccc8220..a267657 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ modern materials and asset fidelity, and a complete PrismStudio UI replacement. This repository is at **0.1.0-experimental.0**. It currently contains the accepted product contract, executable Milestone 0 checks, and the first deterministic state, keyed reconciliation, geometry, intrinsic measurement, -Flex, Grid, Overlay, semantics, Talkback, display-list, CPU-oracle, input, replay, and motion +Flex, Grid, Overlay, scroll, virtual-list, semantics, Talkback, display-list, +CPU-oracle, input, replay, and motion slices. It does not yet contain a usable renderer, window shell, component library, or supported platform backend. Nothing in this repository is a Zagkit 1.0 release. @@ -42,7 +43,7 @@ is normative in [DEPENDENCIES.md](DEPENDENCIES.md). | Product and architecture contract | accepted | [RFC index](docs/rfcs/README.md) | | Compiler dependency | pinned, prerequisites incomplete | [toolchain lock](contracts/toolchain.json) | | Platform shells | unavailable | [support matrix](SUPPORT.md) | -| Headless core | experimental state, reconciliation, intrinsic measurement, geometry, Flex, Grid, Overlay, semantics, Talkback, display lists, CPU raster, input, replay, and motion | [headless test](tools/test-headless.sh) | +| Headless core | experimental state, reconciliation, intrinsic measurement, geometry, Flex, Grid, Overlay, scroll, virtual list, semantics, Talkback, display lists, CPU raster, input, replay, and motion | [headless test](tools/test-headless.sh) | | Components and visual language | inventory only, visual review pending | [component inventory](contracts/components.json) | | Flex and Zagkit Talkback | Flex foundation and in-process ID-first Talkback dispatch executing; native transport remains unavailable | [Talkback contract](docs/automation/talkback.md) | | Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | @@ -65,8 +66,8 @@ experimental. A retained intrinsic tree now aggregates leaf, row, column, and overlay size ranges, rejects invalid ownership and unstable same-revision measurement, resolves constraints with explicit rules and overflow, and records exact state-read layout causes. Typed environment values, reconciliation -cancellation, retained child lifecycle, replay serialization, scroll and virtualized -placement, and full breakpoint policy remain open; the corresponding Milestone +cancellation, retained child lifecycle, replay serialization, Table and Tree +projection, and full breakpoint policy remain open; the corresponding Milestone 2 checklist items are not complete. See the [measurement contract](docs/architecture/measurement.md). @@ -87,6 +88,16 @@ adaptive matrix remain open, so `G2-FLEX` and `G2-FLEX-RTL` are not complete. See the [Flex contract](docs/architecture/flex.md) and [Grid and Overlay contract](docs/architecture/grid-and-overlay.md). +Scroll now retains exact logical offsets, consumed and unconsumed gesture +deltas, reveal alignment, anchor-preserving content reconciliation, RTL visible +coordinates, revisions, and deterministic identity. Virtual List performs +binary visible-range selection across up to one billion stable IDs, supports +sparse variable extents and anchor correction, and hard-limits live placement +allocation. The focused proof queries the middle of one million rows with nine +live placements and fewer than 64 examined records. This is correctness and +bounded-work evidence, not a 120 Hz hardware result. See the +[scroll and virtualization contract](docs/architecture/scroll-and-virtualization.md). + The semantics slice owns copied names and values, stable keys, explicit action capabilities, deterministic focus order, live-region state, ranges, selection, and text-navigation bounds. Invalid parents, duplicate IDs and focus order, diff --git a/docs/architecture/flex.md b/docs/architecture/flex.md index 9979c82..1bf4a32 100644 --- a/docs/architecture/flex.md +++ b/docs/architecture/flex.md @@ -99,7 +99,6 @@ the placement and line arrays. The following remain required before either Flex checklist item can complete: -- grid and overlay placement; - safe-area and density matrices driven by real platform shells; - end-to-end state, environment, and platform-input invalidation reasons across retained components; diff --git a/docs/architecture/measurement.md b/docs/architecture/measurement.md index 3a44371..cd39dca 100644 --- a/docs/architecture/measurement.md +++ b/docs/architecture/measurement.md @@ -110,6 +110,7 @@ The following remain before `G2-CONSTRAINTS` can complete: - serialized measurement and invalidation evidence; - property and fuzz coverage across the complete component layout matrix. -Grid and overlay *placement*, scroll and virtualization, and platform-driven -adaptive conformance remain separate Flex and virtualization work. Overlay -intrinsic aggregation here does not claim overlay placement is implemented. +Table and Tree projection, recycled retained-node lifecycle, and platform-driven +adaptive conformance remain separate Flex and virtualization work. Grid, +Overlay, Scroll, and Virtual List now have experimental placement contracts; +their existence does not complete the full component layout matrix. diff --git a/docs/architecture/scroll-and-virtualization.md b/docs/architecture/scroll-and-virtualization.md new file mode 100644 index 0000000..ba671bb --- /dev/null +++ b/docs/architecture/scroll-and-virtualization.md @@ -0,0 +1,69 @@ +# Scroll and virtual-list contract + +Status: experimental headless primitive + +The current scroll and virtual-list slice supplies deterministic geometry and +bounded retained work without a window system. It is intended to support later +List, Table, Tree, Talkback, and PrismStudio collection work. It does not claim +native wheel, touch, accessibility, or 120 Hz performance certification. + +## Scroll truth + +`ScrollState` owns viewport and content sizes, logical offsets, and a revision. +Mutations report old and new offsets, consumed and unconsumed deltas, whether a +revision changed, and deterministic identity. Unconsumed deltas are explicit so +nested scrolling and gesture arbitration can hand pressure to an ancestor +without guessing. + +Offsets clamp to the exact content boundary. Repeated pressure at a boundary +does not create a phantom revision. Reveal supports nearest, start, center, and +end alignment. Reconciliation can preserve an explicit content point at its +viewport position while content or viewport sizes change. Logical horizontal +start maps to the opposite physical content edge in RTL. + +## Million-row model + +`VirtualListModel` represents as many as one billion stable IDs with a uniform +default extent and up to 4,096 sorted sparse extent overrides. It does not +allocate one record per item. Binary range selection finds the visible range; +only visible plus requested overscan placements are owned by the result. + +Each layout reports: + +- the full semantic item count; +- first and last visible and live indices; +- viewport-local frames and stable IDs; +- content extent, clamped scroll offset, and maximum scroll; +- the number of examined records; +- deterministic input/output identity. + +`max_live_items` is a hard allocation ceiling. A viewport that would exceed it +fails with `live_limit_exceeded` before placement allocation. Invalid sparse +ordering, malformed extents, unsafe scrolling, and content extent overflow also +fail with typed reasons. A viewport containing only collection padding reports +no phantom visible item. + +## Variable extents and anchors + +Sparse overrides carry their own revision. `VirtualAnchor` couples stable ID, +index, and viewport offset. When an extent above the viewport changes, +`virtual_resolve_anchor` computes the new scroll offset that keeps the anchored +item visually stationary. Missing or replaced IDs fail instead of silently +anchoring a different row. + +Horizontal lists retain logical order and mirror placement in RTL. Padding +remains physical. Vertical collection direction does not reorder semantic IDs. + +## Ownership and evidence boundary + +Call `virtual_free` once for every layout result, including failures. The model +borrows its sparse override list; its creator owns that list. Replay mixes +scroll and virtual-list identity with Flex, Grid, and Overlay identity. + +The executable suite proves a one-million-row mid-list query with nine live +placements and fewer than 64 examined records, sparse variable extents, anchor +stability, RTL horizontal placement, exact empty and padding-only ranges, +bounded allocation failure, malformed input, overflow rejection, and a +multi-position property sweep. Stopwatch timing in CI is not a 120 Hz claim. +Table and Tree projection, focus retention across eviction, recycling lifecycle, +native input, semantic adapters, and reference-hardware performance remain open. diff --git a/docs/quality/release-gates.md b/docs/quality/release-gates.md index 4340b0e..bff220d 100644 --- a/docs/quality/release-gates.md +++ b/docs/quality/release-gates.md @@ -43,6 +43,12 @@ closed unless overlap is explicit, and every primitive reports quantified overflow plus deterministic identity. Every layout change reports its exact state read and rule. +Virtualization conformance requires stable semantic identity independent of +live-node residency, variable-extent anchor stability, bounded overscan, +fail-closed allocation ceilings, focus restoration, and no linear work per +scroll frame. A million-row range proof is necessary but does not replace the +120 Hz reference-hardware and ten-minute stall gates. + Zagkit Talkback conformance drives canonical applications by stable ID and checks discovery, query, action, wait, assertion, screenshot, timeline, capability, snapshot, and replay behavior. Pixel fallback is separately diff --git a/src/layout/scroll.zag b/src/layout/scroll.zag new file mode 100644 index 0000000..ea9fa04 --- /dev/null +++ b/src/layout/scroll.zag @@ -0,0 +1,240 @@ +@import("../core/geometry.zag") +@import("common.zag") + +enum ScrollError { + none, + invalid_viewport, + invalid_content, + invalid_offset, + invalid_target, +} + +enum ScrollAlignment { nearest, start, center, end } + +struct ScrollState { + viewport: Size, + content: Size, + offset_x: i64, + offset_y: i64, + revision: i64, +} + +struct ScrollAnchor { + content_x: i64, + content_y: i64, + viewport_x: i64, + viewport_y: i64, + preserve_x: i32, + preserve_y: i32, +} + +struct ScrollMutation { + old_offset_x: i64, + old_offset_y: i64, + offset_x: i64, + offset_y: i64, + consumed_x: i64, + consumed_y: i64, + unconsumed_x: i64, + unconsumed_y: i64, + revision: i64, + changed: i32, + mutation_hash: i64, + error: ScrollError, +} + +fn scroll_mix(hash: i64, value: i64) i64 { return (hash * 1099511628211) ^ value; } + +fn scroll_size_valid(value: Size) i32 { + return (value.width >= 0 && value.height >= 0 && + value.width <= unbounded_extent() && value.height <= unbounded_extent()) as i32; +} + +fn scroll_state(viewport: Size, content: Size) ScrollState { + return ScrollState{ + .viewport = viewport, .content = content, + .offset_x = 0, .offset_y = 0, .revision = 0, + }; +} + +fn scroll_max_x(state: ScrollState) i64 { + let value: i64 = state.content.width - state.viewport.width; + if (value < 0) { return 0; } + return value; +} + +fn scroll_max_y(state: ScrollState) i64 { + let value: i64 = state.content.height - state.viewport.height; + if (value < 0) { return 0; } + return value; +} + +fn scroll_state_valid(state: ScrollState) ScrollError { + if (scroll_size_valid(state.viewport) == 0) { return ScrollError.invalid_viewport; } + if (scroll_size_valid(state.content) == 0) { return ScrollError.invalid_content; } + if (state.revision < 0 || state.offset_x < 0 || state.offset_y < 0 || + state.offset_x > scroll_max_x(state) || state.offset_y > scroll_max_y(state)) { + return ScrollError.invalid_offset; + } + return ScrollError.none; +} + +fn scroll_state_hash(state: ScrollState) i64 { + let hash: i64 = scroll_mix(scroll_mix(17, state.viewport.width), state.viewport.height); + hash = scroll_mix(hash, state.content.width); hash = scroll_mix(hash, state.content.height); + hash = scroll_mix(hash, state.offset_x); hash = scroll_mix(hash, state.offset_y); + return scroll_mix(hash, state.revision); +} + +fn scroll_mutation_hash(result: ScrollMutation) i64 { + let hash: i64 = scroll_mix(scroll_mix(17, result.old_offset_x), result.old_offset_y); + hash = scroll_mix(hash, result.offset_x); hash = scroll_mix(hash, result.offset_y); + hash = scroll_mix(hash, result.consumed_x); hash = scroll_mix(hash, result.consumed_y); + hash = scroll_mix(hash, result.unconsumed_x); hash = scroll_mix(hash, result.unconsumed_y); + hash = scroll_mix(hash, result.revision); hash = scroll_mix(hash, result.changed as i64); + return scroll_mix(hash, result.error as i64); +} + +fn scroll_error_mutation(state: ScrollState, error: ScrollError) ScrollMutation { + let result: ScrollMutation = ScrollMutation{ + .old_offset_x = state.offset_x, .old_offset_y = state.offset_y, + .offset_x = state.offset_x, .offset_y = state.offset_y, + .consumed_x = 0, .consumed_y = 0, .unconsumed_x = 0, .unconsumed_y = 0, + .revision = state.revision, .changed = 0, .mutation_hash = 0, .error = error, + }; + result.mutation_hash = scroll_mutation_hash(result); + return result; +} + +fn scroll_apply(state: *ScrollState, raw_x: i64, raw_y: i64, requested_x: i64, + requested_y: i64) ScrollMutation { + let validation: ScrollError = scroll_state_valid(state.*); + if (validation != ScrollError.none) { return scroll_error_mutation(state.*, validation); } + if (raw_x < 0 - unbounded_extent() || raw_x > unbounded_extent() * 2 || + raw_y < 0 - unbounded_extent() || raw_y > unbounded_extent() * 2 || + requested_x < 0 - unbounded_extent() || requested_x > unbounded_extent() || + requested_y < 0 - unbounded_extent() || requested_y > unbounded_extent()) { + return scroll_error_mutation(state.*, ScrollError.invalid_offset); + } + let old_x: i64 = state.*.offset_x; + let old_y: i64 = state.*.offset_y; + let next_x: i64 = clamp_i64(raw_x, 0, scroll_max_x(state.*)); + let next_y: i64 = clamp_i64(raw_y, 0, scroll_max_y(state.*)); + let changed: i32 = (next_x != old_x || next_y != old_y) as i32; + if (changed != 0) { state.*.revision = state.*.revision + 1; } + state.*.offset_x = next_x; state.*.offset_y = next_y; + let consumed_x: i64 = next_x - old_x; + let consumed_y: i64 = next_y - old_y; + let result: ScrollMutation = ScrollMutation{ + .old_offset_x = old_x, .old_offset_y = old_y, + .offset_x = next_x, .offset_y = next_y, + .consumed_x = consumed_x, .consumed_y = consumed_y, + .unconsumed_x = requested_x - consumed_x, + .unconsumed_y = requested_y - consumed_y, + .revision = state.*.revision, .changed = changed, + .mutation_hash = 0, .error = ScrollError.none, + }; + result.mutation_hash = scroll_mutation_hash(result); + return result; +} + +fn scroll_to(state: *ScrollState, x: i64, y: i64) ScrollMutation { + return scroll_apply(state, x, y, x - state.*.offset_x, y - state.*.offset_y); +} + +fn scroll_by(state: *ScrollState, delta_x: i64, delta_y: i64) ScrollMutation { + if (delta_x < 0 - unbounded_extent() || delta_x > unbounded_extent() || + delta_y < 0 - unbounded_extent() || delta_y > unbounded_extent()) { + return scroll_error_mutation(state.*, ScrollError.invalid_offset); + } + return scroll_apply(state, state.*.offset_x + delta_x, state.*.offset_y + delta_y, + delta_x, delta_y); +} + +fn scroll_anchor(content_x: i64, content_y: i64, viewport_x: i64, viewport_y: i64) ScrollAnchor { + return ScrollAnchor{ + .content_x = content_x, .content_y = content_y, + .viewport_x = viewport_x, .viewport_y = viewport_y, + .preserve_x = 1, .preserve_y = 1, + }; +} + +fn scroll_reconcile(state: *ScrollState, viewport: Size, content: Size, + anchor: ScrollAnchor) ScrollMutation { + let old: ScrollState = state.*; + let old_validation: ScrollError = scroll_state_valid(old); + if (old_validation != ScrollError.none) { return scroll_error_mutation(old, old_validation); } + if (scroll_size_valid(viewport) == 0) { return scroll_error_mutation(old, ScrollError.invalid_viewport); } + if (scroll_size_valid(content) == 0) { return scroll_error_mutation(old, ScrollError.invalid_content); } + if ((anchor.preserve_x != 0 && anchor.preserve_x != 1) || + (anchor.preserve_y != 0 && anchor.preserve_y != 1) || + anchor.content_x < 0 || anchor.content_y < 0 || + anchor.viewport_x < 0 - unbounded_extent() || anchor.viewport_x > unbounded_extent() || + anchor.viewport_y < 0 - unbounded_extent() || anchor.viewport_y > unbounded_extent()) { + return scroll_error_mutation(old, ScrollError.invalid_target); + } + state.*.viewport = viewport; state.*.content = content; + let raw_x: i64 = old.offset_x; + let raw_y: i64 = old.offset_y; + if (anchor.preserve_x != 0) { raw_x = anchor.content_x - anchor.viewport_x; } + if (anchor.preserve_y != 0) { raw_y = anchor.content_y - anchor.viewport_y; } + state.*.offset_x = clamp_i64(raw_x, 0, scroll_max_x(state.*)); + state.*.offset_y = clamp_i64(raw_y, 0, scroll_max_y(state.*)); + let changed: i32 = (state.*.viewport.width != old.viewport.width || + state.*.viewport.height != old.viewport.height || + state.*.content.width != old.content.width || + state.*.content.height != old.content.height || + state.*.offset_x != old.offset_x || state.*.offset_y != old.offset_y) as i32; + if (changed != 0) { state.*.revision = old.revision + 1; } + let result: ScrollMutation = ScrollMutation{ + .old_offset_x = old.offset_x, .old_offset_y = old.offset_y, + .offset_x = state.*.offset_x, .offset_y = state.*.offset_y, + .consumed_x = state.*.offset_x - old.offset_x, + .consumed_y = state.*.offset_y - old.offset_y, + .unconsumed_x = 0, .unconsumed_y = 0, + .revision = state.*.revision, .changed = changed, + .mutation_hash = 0, .error = ScrollError.none, + }; + result.mutation_hash = scroll_mutation_hash(result); + return result; +} + +fn scroll_axis_reveal(current: i64, viewport: i64, target_start: i64, + target_extent: i64, alignment: ScrollAlignment) i64 { + if (alignment == ScrollAlignment.start) { return target_start; } + if (alignment == ScrollAlignment.center) { + return target_start + target_extent / 2 - viewport / 2; + } + if (alignment == ScrollAlignment.end) { return target_start + target_extent - viewport; } + if (target_extent >= viewport) { return target_start; } + if (target_start < current) { return target_start; } + if (target_start + target_extent > current + viewport) { + return target_start + target_extent - viewport; + } + return current; +} + +fn scroll_reveal(state: *ScrollState, target: Rect, horizontal: ScrollAlignment, + vertical: ScrollAlignment) ScrollMutation { + if (target.x < 0 || target.y < 0 || target.width < 0 || target.height < 0 || + target.x > unbounded_extent() - target.width || + target.y > unbounded_extent() - target.height) { + return scroll_error_mutation(state.*, ScrollError.invalid_target); + } + let raw_x: i64 = scroll_axis_reveal(state.*.offset_x, state.*.viewport.width, + target.x, target.width, horizontal); + let raw_y: i64 = scroll_axis_reveal(state.*.offset_y, state.*.viewport.height, + target.y, target.height, vertical); + return scroll_apply(state, raw_x, raw_y, + raw_x - state.*.offset_x, raw_y - state.*.offset_y); +} + +fn scroll_visible_rect(state: ScrollState, direction: LayoutDirection) Rect { + let width: i64 = state.viewport.width; + if (state.content.width < width) { width = state.content.width; } + let height: i64 = state.viewport.height; + if (state.content.height < height) { height = state.content.height; } + let x: i64 = state.offset_x; + if (direction == LayoutDirection.rtl) { x = scroll_max_x(state) - state.offset_x; } + return rect(x, state.offset_y, width, height); +} diff --git a/src/layout/virtual_list.zag b/src/layout/virtual_list.zag new file mode 100644 index 0000000..41417e1 --- /dev/null +++ b/src/layout/virtual_list.zag @@ -0,0 +1,439 @@ +@import("std:list") +@import("../core/geometry.zag") +@import("common.zag") + +enum VirtualAxis { vertical, horizontal } +enum VirtualListError { + none, + invalid_model, + invalid_override, + invalid_viewport, + invalid_scroll, + invalid_style, + invalid_anchor, + arithmetic_overflow, + live_limit_exceeded, +} + +struct VirtualExtentOverride { + index: i64, + extent: i64, + revision: i64, +} + +struct VirtualListModel { + count: i64, + id_base: i64, + default_extent: i64, + gap: i64, + revision: i64, + overrides: ArrayList[VirtualExtentOverride], +} + +struct VirtualListStyle { + axis: VirtualAxis, + direction: LayoutDirection, + padding: Insets, + overscan_before: i32, + overscan_after: i32, + max_live_items: i32, +} + +struct VirtualPlacement { + id: i64, + index: i64, + frame: Rect, + extent_revision: i64, +} + +struct VirtualAnchor { + id: i64, + index: i64, + viewport_offset: i64, +} + +struct VirtualAnchorResolution { + scroll_offset: i64, + changed: i32, + error: VirtualListError, +} + +struct VirtualListResult { + placements: ArrayList[VirtualPlacement], + viewport: Size, + content: Size, + scroll_offset: i64, + max_scroll: i64, + first_visible: i64, + last_visible: i64, + first_live: i64, + last_live: i64, + semantic_count: i64, + examined_items: i32, + layout_hash: i64, + error: VirtualListError, + error_index: i64, +} + +fn virtual_override(index: i64, extent: i64, revision: i64) VirtualExtentOverride { + return VirtualExtentOverride{ .index = index, .extent = extent, .revision = revision }; +} + +fn virtual_model(count: i64, id_base: i64, default_extent: i64, + gap: i64, revision: i64, overrides: ArrayList[VirtualExtentOverride]) VirtualListModel { + return VirtualListModel{ + .count = count, .id_base = id_base, .default_extent = default_extent, + .gap = gap, .revision = revision, .overrides = overrides, + }; +} + +fn virtual_style(axis: VirtualAxis, padding: Insets) VirtualListStyle { + return VirtualListStyle{ + .axis = axis, .direction = LayoutDirection.ltr, .padding = padding, + .overscan_before = 2, .overscan_after = 2, .max_live_items = 512, + }; +} + +fn virtual_free(result: *VirtualListResult) void { + free[VirtualPlacement](&result.*.placements); +} + +fn virtual_mix(hash: i64, value: i64) i64 { return (hash * 1099511628211) ^ value; } + +fn virtual_error_result(error: VirtualListError, index: i64) VirtualListResult { + let result: VirtualListResult = VirtualListResult{ + .placements = make[VirtualPlacement](1), + .viewport = size(0, 0), .content = size(0, 0), + .scroll_offset = 0, .max_scroll = 0, + .first_visible = 0 - 1, .last_visible = 0 - 1, + .first_live = 0 - 1, .last_live = 0 - 1, + .semantic_count = 0, .examined_items = 0, + .layout_hash = 17, .error = error, .error_index = index, + }; + result.layout_hash = virtual_mix(virtual_mix(17, error as i64), index); + return result; +} + +fn virtual_extent_valid(value: i64) i32 { + return (value >= 0 && value <= unbounded_extent() / 4) as i32; +} + +fn virtual_insets_valid(value: Insets) i32 { + return (virtual_extent_valid(value.top) == 1 && virtual_extent_valid(value.right) == 1 && + virtual_extent_valid(value.bottom) == 1 && virtual_extent_valid(value.left) == 1) as i32; +} + +fn virtual_model_error(model: VirtualListModel) VirtualListError { + if (model.count < 0 || model.count > 1000000000 || model.id_base <= 0 || + model.default_extent <= 0 || model.default_extent > unbounded_extent() / 4 || + model.gap < 0 || model.gap > unbounded_extent() / 4 || + model.revision < 0 || model.overrides.len > 4096 || + (model.count > 0 && model.id_base > unbounded_extent() - model.count)) { + return VirtualListError.invalid_model; + } + let i: i32 = 0; + while (i < model.overrides.len) { + let item: VirtualExtentOverride = model.overrides.data[i]; + if (item.index < 0 || item.index >= model.count || item.extent <= 0 || + item.extent > unbounded_extent() / 4 || item.revision < 0 || + (i > 0 && model.overrides.data[i - 1].index >= item.index)) { + return VirtualListError.invalid_override; + } + i = i + 1; + } + return VirtualListError.none; +} + +fn virtual_style_valid(style: VirtualListStyle) i32 { + return (virtual_insets_valid(style.padding) == 1 && style.overscan_before >= 0 && + style.overscan_after >= 0 && style.overscan_before <= 4096 && + style.overscan_after <= 4096 && style.max_live_items > 0 && + style.max_live_items <= 4096) as i32; +} + +fn virtual_content_extent(model: VirtualListModel, style: VirtualListStyle) i64 { + let leading: i64 = style.padding.top; + let trailing: i64 = style.padding.bottom; + if (style.axis == VirtualAxis.horizontal) { + leading = style.padding.left; trailing = style.padding.right; + } + let total: i64 = leading + trailing; + if (total < 0 || total > unbounded_extent()) { return 0 - 1; } + if (model.count == 0) { return total; } + let stride: i64 = model.default_extent + model.gap; + if (stride <= 0 || model.count > (unbounded_extent() - total) / stride) { return 0 - 1; } + total = total + model.count * stride - model.gap; + let i: i32 = 0; + while (i < model.overrides.len) { + total = total + model.overrides.data[i].extent - model.default_extent; + if (total < 0 || total > unbounded_extent()) { return 0 - 1; } + i = i + 1; + } + return total; +} + +fn virtual_item_extent(model: VirtualListModel, index: i64) i64 { + let i: i32 = 0; + while (i < model.overrides.len) { + if (model.overrides.data[i].index == index) { return model.overrides.data[i].extent; } + if (model.overrides.data[i].index > index) { return model.default_extent; } + i = i + 1; + } + return model.default_extent; +} + +fn virtual_item_revision(model: VirtualListModel, index: i64) i64 { + let i: i32 = 0; + while (i < model.overrides.len) { + if (model.overrides.data[i].index == index) { return model.overrides.data[i].revision; } + if (model.overrides.data[i].index > index) { return model.revision; } + i = i + 1; + } + return model.revision; +} + +fn virtual_item_offset(model: VirtualListModel, style: VirtualListStyle, index: i64) i64 { + let leading: i64 = style.padding.top; + if (style.axis == VirtualAxis.horizontal) { + if (style.direction == LayoutDirection.ltr) { leading = style.padding.left; } + else { leading = style.padding.right; } + } + let offset: i64 = leading + index * (model.default_extent + model.gap); + let i: i32 = 0; + while (i < model.overrides.len && model.overrides.data[i].index < index) { + offset = offset + model.overrides.data[i].extent - model.default_extent; + i = i + 1; + } + return offset; +} + +fn virtual_first_end_after(model: VirtualListModel, style: VirtualListStyle, + position: i64, steps: *i32) i64 { + let low: i64 = 0; + let high: i64 = model.count; + while (low < high) { + let mid: i64 = low + (high - low) / 2; + let finish: i64 = virtual_item_offset(model, style, mid) + virtual_item_extent(model, mid); + steps.* = steps.* + 1; + if (finish <= position) { low = mid + 1; } + else { high = mid; } + } + return low; +} + +fn virtual_first_start_at_or_after(model: VirtualListModel, style: VirtualListStyle, + position: i64, steps: *i32) i64 { + let low: i64 = 0; + let high: i64 = model.count; + while (low < high) { + let mid: i64 = low + (high - low) / 2; + let start: i64 = virtual_item_offset(model, style, mid); + steps.* = steps.* + 1; + if (start < position) { low = mid + 1; } + else { high = mid; } + } + return low; +} + +fn virtual_input_hash(model: VirtualListModel, viewport: Size, raw_scroll: i64, + style: VirtualListStyle) i64 { + let hash: i64 = virtual_mix(virtual_mix(17, model.count), model.id_base); + hash = virtual_mix(hash, model.default_extent); hash = virtual_mix(hash, model.gap); + hash = virtual_mix(hash, model.revision); hash = virtual_mix(hash, viewport.width); + hash = virtual_mix(hash, viewport.height); hash = virtual_mix(hash, raw_scroll); + hash = virtual_mix(hash, style.axis as i64); hash = virtual_mix(hash, style.direction as i64); + hash = virtual_mix(hash, style.padding.top); hash = virtual_mix(hash, style.padding.right); + hash = virtual_mix(hash, style.padding.bottom); hash = virtual_mix(hash, style.padding.left); + hash = virtual_mix(hash, style.overscan_before as i64); + hash = virtual_mix(hash, style.overscan_after as i64); + hash = virtual_mix(hash, style.max_live_items as i64); + let i: i32 = 0; + while (i < model.overrides.len) { + hash = virtual_mix(hash, model.overrides.data[i].index); + hash = virtual_mix(hash, model.overrides.data[i].extent); + hash = virtual_mix(hash, model.overrides.data[i].revision); + i = i + 1; + } + return hash; +} + +fn virtual_output_hash(result: VirtualListResult) i64 { + let hash: i64 = virtual_mix(virtual_mix(17, result.content.width), result.content.height); + hash = virtual_mix(hash, result.scroll_offset); hash = virtual_mix(hash, result.max_scroll); + hash = virtual_mix(hash, result.first_visible); hash = virtual_mix(hash, result.last_visible); + hash = virtual_mix(hash, result.first_live); hash = virtual_mix(hash, result.last_live); + hash = virtual_mix(hash, result.semantic_count); + hash = virtual_mix(hash, result.examined_items as i64); + let i: i32 = 0; + while (i < result.placements.len) { + let placement: VirtualPlacement = result.placements.data[i]; + hash = virtual_mix(hash, placement.id); hash = virtual_mix(hash, placement.index); + hash = virtual_mix(hash, placement.frame.x); hash = virtual_mix(hash, placement.frame.y); + hash = virtual_mix(hash, placement.frame.width); hash = virtual_mix(hash, placement.frame.height); + hash = virtual_mix(hash, placement.extent_revision); + i = i + 1; + } + return hash; +} + +fn virtual_list_layout(model: VirtualListModel, viewport: Size, raw_scroll: i64, + style: VirtualListStyle) VirtualListResult { + let model_error: VirtualListError = virtual_model_error(model); + if (model_error != VirtualListError.none) { + let index: i64 = 0 - 1; + if (model_error == VirtualListError.invalid_override) { + index = 0; + while (index < model.overrides.len && model.overrides.data[index as i32].index >= 0 && + model.overrides.data[index as i32].index < model.count && + model.overrides.data[index as i32].extent > 0 && + model.overrides.data[index as i32].extent <= unbounded_extent() / 4 && + model.overrides.data[index as i32].revision >= 0 && + (index == 0 || model.overrides.data[(index - 1) as i32].index < + model.overrides.data[index as i32].index)) { index = index + 1; } + } + return virtual_error_result(model_error, index); + } + if (viewport.width <= 0 || viewport.height <= 0 || + viewport.width > unbounded_extent() || viewport.height > unbounded_extent()) { + return virtual_error_result(VirtualListError.invalid_viewport, 0 - 1); + } + if (virtual_style_valid(style) == 0) { + return virtual_error_result(VirtualListError.invalid_style, 0 - 1); + } + if (raw_scroll < 0 - unbounded_extent() || raw_scroll > unbounded_extent() * 2) { + return virtual_error_result(VirtualListError.invalid_scroll, 0 - 1); + } + let axis_extent: i64 = viewport.height; + if (style.axis == VirtualAxis.horizontal) { axis_extent = viewport.width; } + let content_extent: i64 = virtual_content_extent(model, style); + if (content_extent < 0) { return virtual_error_result(VirtualListError.arithmetic_overflow, 0 - 1); } + let max_scroll: i64 = content_extent - axis_extent; + if (max_scroll < 0) { max_scroll = 0; } + let scroll_offset: i64 = clamp_i64(raw_scroll, 0, max_scroll); + let content: Size = size(viewport.width, content_extent); + if (style.axis == VirtualAxis.horizontal) { content = size(content_extent, viewport.height); } + + if (model.count == 0) { + let empty: VirtualListResult = VirtualListResult{ + .placements = make[VirtualPlacement](1), .viewport = viewport, .content = content, + .scroll_offset = scroll_offset, .max_scroll = max_scroll, + .first_visible = 0 - 1, .last_visible = 0 - 1, + .first_live = 0 - 1, .last_live = 0 - 1, + .semantic_count = 0, .examined_items = 0, .layout_hash = 0, + .error = VirtualListError.none, .error_index = 0 - 1, + }; + empty.layout_hash = virtual_mix(virtual_input_hash(model, viewport, raw_scroll, style), + virtual_output_hash(empty)); + return empty; + } + + let examined: i32 = 0; + let first_visible: i64 = virtual_first_end_after(model, style, scroll_offset, &examined); + let after_visible: i64 = virtual_first_start_at_or_after( + model, style, scroll_offset + axis_extent, &examined); + if (first_visible >= model.count || + virtual_item_offset(model, style, first_visible) >= scroll_offset + axis_extent) { + let padding_only: VirtualListResult = VirtualListResult{ + .placements = make[VirtualPlacement](1), .viewport = viewport, .content = content, + .scroll_offset = scroll_offset, .max_scroll = max_scroll, + .first_visible = 0 - 1, .last_visible = 0 - 1, + .first_live = 0 - 1, .last_live = 0 - 1, + .semantic_count = model.count, .examined_items = examined, .layout_hash = 0, + .error = VirtualListError.none, .error_index = 0 - 1, + }; + padding_only.layout_hash = virtual_mix( + virtual_input_hash(model, viewport, raw_scroll, style), + virtual_output_hash(padding_only) + ); + return padding_only; + } + let last_visible: i64 = after_visible - 1; + if (last_visible < first_visible) { last_visible = first_visible; } + if (last_visible >= model.count) { last_visible = model.count - 1; } + let first_live: i64 = first_visible - style.overscan_before as i64; + if (first_live < 0) { first_live = 0; } + let last_live: i64 = last_visible + style.overscan_after as i64; + if (last_live >= model.count) { last_live = model.count - 1; } + let live_count: i64 = last_live - first_live + 1; + if (live_count > style.max_live_items as i64) { + return virtual_error_result(VirtualListError.live_limit_exceeded, first_visible); + } + let placements: ArrayList[VirtualPlacement] = make[VirtualPlacement](live_count as i32); + let index: i64 = first_live; + while (index <= last_live) { + let offset: i64 = virtual_item_offset(model, style, index); + let extent: i64 = virtual_item_extent(model, index); + let frame: Rect = rect(0, 0, 0, 0); + if (style.axis == VirtualAxis.vertical) { + frame.x = style.padding.left; + frame.y = offset - scroll_offset; + frame.width = viewport.width - style.padding.left - style.padding.right; + if (frame.width < 0) { frame.width = 0; } + frame.height = extent; + } else { + frame.y = style.padding.top; + frame.height = viewport.height - style.padding.top - style.padding.bottom; + if (frame.height < 0) { frame.height = 0; } + frame.width = extent; + frame.x = offset - scroll_offset; + if (style.direction == LayoutDirection.rtl) { + frame.x = viewport.width - (offset - scroll_offset) - extent; + } + } + push[VirtualPlacement](&placements, VirtualPlacement{ + .id = model.id_base + index, .index = index, .frame = frame, + .extent_revision = virtual_item_revision(model, index), + }); + examined = examined + 1; + index = index + 1; + } + let result: VirtualListResult = VirtualListResult{ + .placements = placements, .viewport = viewport, .content = content, + .scroll_offset = scroll_offset, .max_scroll = max_scroll, + .first_visible = first_visible, .last_visible = last_visible, + .first_live = first_live, .last_live = last_live, + .semantic_count = model.count, .examined_items = examined, + .layout_hash = 0, .error = VirtualListError.none, .error_index = 0 - 1, + }; + result.layout_hash = virtual_mix(virtual_input_hash(model, viewport, raw_scroll, style), + virtual_output_hash(result)); + return result; +} + +fn virtual_anchor_at(model: VirtualListModel, style: VirtualListStyle, + index: i64, scroll_offset: i64) VirtualAnchor { + if (index < 0 || index >= model.count) { + return VirtualAnchor{ .id = 0, .index = 0 - 1, .viewport_offset = 0 }; + } + return VirtualAnchor{ + .id = model.id_base + index, .index = index, + .viewport_offset = virtual_item_offset(model, style, index) - scroll_offset, + }; +} + +fn virtual_resolve_anchor(model: VirtualListModel, style: VirtualListStyle, + anchor: VirtualAnchor, viewport_extent: i64, old_scroll: i64) VirtualAnchorResolution { + if (virtual_model_error(model) != VirtualListError.none || + virtual_style_valid(style) == 0 || + anchor.index < 0 || anchor.index >= model.count || + anchor.id != model.id_base + anchor.index || viewport_extent <= 0 || + viewport_extent > unbounded_extent()) { + return VirtualAnchorResolution{ + .scroll_offset = old_scroll, .changed = 0, .error = VirtualListError.invalid_anchor, + }; + } + let content_extent: i64 = virtual_content_extent(model, style); + if (content_extent < 0) { + return VirtualAnchorResolution{ + .scroll_offset = old_scroll, .changed = 0, .error = VirtualListError.arithmetic_overflow, + }; + } + let max_scroll: i64 = content_extent - viewport_extent; + if (max_scroll < 0) { max_scroll = 0; } + let raw: i64 = virtual_item_offset(model, style, anchor.index) - anchor.viewport_offset; + let resolved: i64 = clamp_i64(raw, 0, max_scroll); + return VirtualAnchorResolution{ + .scroll_offset = resolved, .changed = (resolved != old_scroll) as i32, + .error = VirtualListError.none, + }; +} diff --git a/src/replay/replay.zag b/src/replay/replay.zag index 6f3c851..813445f 100644 --- a/src/replay/replay.zag +++ b/src/replay/replay.zag @@ -5,6 +5,8 @@ @import("../layout/flex_wrap.zag") @import("../layout/grid.zag") @import("../layout/overlay.zag") +@import("../layout/scroll.zag") +@import("../layout/virtual_list.zag") @import("../semantics/semantics.zag") @import("../render/cpu_raster.zag") @import("../motion/motion.zag") @@ -458,11 +460,47 @@ fn replay_build_scene(state_value: i64, backend_generation: i64) ReplayResult { semantics_tree_free(&semantics); return replay_result_error(ReplayError.scene_error, 0 - 1, 0); } + let virtual_overrides: ArrayList[VirtualExtentOverride] = make[VirtualExtentOverride](1); + let virtual_model_value: VirtualListModel = virtual_model( + 1000, 2000, unit_scale(), 0, magnitude, virtual_overrides + ); + let virtual_style_value: VirtualListStyle = virtual_style( + VirtualAxis.vertical, insets_all(0) + ); + let virtual_result: VirtualListResult = virtual_list_layout( + virtual_model_value, size(8 * unit_scale(), 6 * unit_scale()), + magnitude * unit_scale(), virtual_style_value + ); + free[VirtualExtentOverride](&virtual_overrides); + if (virtual_result.error != VirtualListError.none) { + virtual_free(&virtual_result); + grid_free(&grid_layout_result); + overlay_free(&overlay_layout_result); + flex_wrap_free(&layout); + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } + let replay_scroll: ScrollState = scroll_state(virtual_result.viewport, virtual_result.content); + let replay_scroll_mutation: ScrollMutation = scroll_to( + &replay_scroll, 0, virtual_result.scroll_offset + ); + if (replay_scroll_mutation.error != ScrollError.none) { + virtual_free(&virtual_result); + grid_free(&grid_layout_result); + overlay_free(&overlay_layout_result); + flex_wrap_free(&layout); + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } result.layout_hash = display_mix( - display_mix(layout.layout_hash, overlay_layout_result.layout_hash), - grid_layout_result.layout_hash + display_mix( + display_mix(layout.layout_hash, overlay_layout_result.layout_hash), + grid_layout_result.layout_hash + ), + display_mix(virtual_result.layout_hash, scroll_state_hash(replay_scroll)) ); result.layout_lines = layout.lines.len; + virtual_free(&virtual_result); grid_free(&grid_layout_result); overlay_free(&overlay_layout_result); diff --git a/tests/replay_contract.zag b/tests/replay_contract.zag index d0855aa..2b5a5af 100644 --- a/tests/replay_contract.zag +++ b/tests/replay_contract.zag @@ -159,7 +159,7 @@ fn differential_contract(state: *TestState) void { expect(state, a.output_hash != b.output_hash && a.motion_hash != b.motion_hash && a.semantics_hash != b.semantics_hash && a.measure_hash != b.measure_hash && a.layout_hash != b.layout_hash && a.display_hash != b.display_hash && a.cpu_hash != b.cpu_hash, - "state changes propagate through motion semantics measurement Flex display and CPU output"); + "state changes propagate through motion semantics measurement layout display and CPU output"); expect(state, a.layout_lines != b.layout_lines, "state replay crosses Flex wrapping boundaries deterministically"); expect(state, a.input_hash != c.input_hash && a.motion_hash == c.motion_hash && diff --git a/tests/scroll_virtual_contract.zag b/tests/scroll_virtual_contract.zag new file mode 100644 index 0000000..e787145 --- /dev/null +++ b/tests/scroll_virtual_contract.zag @@ -0,0 +1,243 @@ +@import("std:list") +@import("../src/layout/scroll.zag") +@import("../src/layout/virtual_list.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn scroll_contract(state: *TestState) void { + let value: ScrollState = scroll_state(size(100, 80), size(300, 200)); + let first: ScrollMutation = scroll_by(&value, 50, 30); + expect(state, first.error == ScrollError.none && first.consumed_x == 50 && + first.consumed_y == 30 && first.unconsumed_x == 0 && first.unconsumed_y == 0 && + value.offset_x == 50 && value.offset_y == 30 && value.revision == 1, + "scroll consumes exact in-bounds deltas and advances one revision"); + let after_first_hash: i64 = scroll_state_hash(value); + let edge: ScrollMutation = scroll_by(&value, 500, 500); + expect(state, edge.offset_x == 200 && edge.offset_y == 120 && + edge.consumed_x == 150 && edge.consumed_y == 90 && + edge.unconsumed_x == 350 && edge.unconsumed_y == 410 && value.revision == 2, + "scroll exposes unconsumed boundary deltas for nested gesture handoff"); + let stopped: ScrollMutation = scroll_by(&value, 10, 10); + expect(state, stopped.changed == 0 && stopped.consumed_x == 0 && stopped.consumed_y == 0 && + stopped.unconsumed_x == 10 && stopped.unconsumed_y == 10 && value.revision == 2, + "boundary pressure performs no phantom state revision"); + let repeated: ScrollState = scroll_state(size(100, 80), size(300, 200)); + let repeated_result: ScrollMutation = scroll_by(&repeated, 50, 30); + expect(state, repeated_result.mutation_hash == first.mutation_hash && + scroll_state_hash(repeated) == after_first_hash, + "identical scroll inputs produce identical mutation and retained-state identity"); +} + +fn reveal_anchor_and_rtl_contract(state: *TestState) void { + let value: ScrollState = scroll_state(size(100, 80), size(300, 200)); + let reveal: ScrollMutation = scroll_reveal( + &value, rect(150, 90, 20, 10), ScrollAlignment.nearest, ScrollAlignment.nearest + ); + expect(state, reveal.error == ScrollError.none && value.offset_x == 70 && value.offset_y == 20, + "nearest reveal moves only enough to expose the complete target"); + let centered: ScrollMutation = scroll_reveal( + &value, rect(200, 100, 20, 20), ScrollAlignment.center, ScrollAlignment.center + ); + expect(state, centered.offset_x == 160 && centered.offset_y == 70, + "center reveal resolves both axes against the authoritative viewport"); + let logical: ScrollMutation = scroll_to(&value, 20, 40); + let ltr: Rect = scroll_visible_rect(value, LayoutDirection.ltr); + let rtl: Rect = scroll_visible_rect(value, LayoutDirection.rtl); + expect(state, logical.error == ScrollError.none && ltr.x == 20 && rtl.x == 180 && + ltr.y == rtl.y && ltr.width == 100 && ltr.height == 80, + "logical horizontal scroll start mirrors to the physical RTL content edge"); + + let anchor: ScrollAnchor = scroll_anchor(40, 160, 20, 20); + let reconciled: ScrollMutation = scroll_reconcile(&value, size(120, 90), size(360, 260), anchor); + expect(state, reconciled.error == ScrollError.none && value.offset_x == 20 && value.offset_y == 140 && + value.viewport.width == 120 && value.content.height == 260 && reconciled.changed == 1, + "content reconciliation preserves an explicit anchor viewport position"); + let before_hash: i64 = scroll_state_hash(value); + let same: ScrollMutation = scroll_reconcile(&value, value.viewport, value.content, + scroll_anchor(value.offset_x, value.offset_y, 0, 0)); + expect(state, same.changed == 0 && same.revision == value.revision && + scroll_state_hash(value) == before_hash, + "identical anchored reconciliation stays completely idle"); + + let malformed: ScrollState = scroll_state(size(0 - 1, 20), size(30, 30)); + let rejected: ScrollMutation = scroll_by(&malformed, 1, 1); + expect(state, rejected.error == ScrollError.invalid_viewport && rejected.changed == 0, + "malformed scroll state fails before mutation"); + let bad_target: ScrollMutation = scroll_reveal(&value, rect(0 - 1, 0, 2, 2), + ScrollAlignment.nearest, ScrollAlignment.nearest); + expect(state, bad_target.error == ScrollError.invalid_target, + "malformed reveal geometry fails closed"); +} + +fn million_row_contract(state: *TestState) void { + let overrides: ArrayList[VirtualExtentOverride] = make[VirtualExtentOverride](1); + let model: VirtualListModel = virtual_model(1000000, 1000, 40, 2, 7, overrides); + let style: VirtualListStyle = virtual_style(VirtualAxis.vertical, + Insets{ .top = 10, .right = 5, .bottom = 10, .left = 5 }); + let target_offset: i64 = virtual_item_offset(model, style, 500000) + 5; + let result: VirtualListResult = virtual_list_layout(model, size(300, 200), target_offset, style); + expect(state, result.error == VirtualListError.none && result.semantic_count == 1000000 && + result.first_visible == 500000 && result.last_visible == 500004, + "million-row layout finds the exact semantic visible range without a linear scan"); + expect(state, result.first_live == 499998 && result.last_live == 500006 && + result.placements.len == 9 && result.examined_items < 64, + "million-row virtualization keeps live nodes and examined work independently bounded"); + expect(state, result.placements.data[2].id == 501000 && + result.placements.data[2].index == 500000 && result.placements.data[2].frame.y == 0 - 5 && + result.placements.data[2].frame.x == 5 && result.placements.data[2].frame.width == 290, + "virtual placement retains stable IDs and exact viewport-local geometry"); + let repeated: VirtualListResult = virtual_list_layout(model, size(300, 200), target_offset, style); + expect(state, repeated.layout_hash == result.layout_hash && result.layout_hash != 0, + "identical million-row inputs produce bit-identical virtual layout identity"); + virtual_free(&repeated); virtual_free(&result); free[VirtualExtentOverride](&overrides); +} + +fn variable_anchor_contract(state: *TestState) void { + let overrides: ArrayList[VirtualExtentOverride] = make[VirtualExtentOverride](2); + push[VirtualExtentOverride](&overrides, virtual_override(2, 30, 1)); + push[VirtualExtentOverride](&overrides, virtual_override(5, 5, 1)); + let model: VirtualListModel = virtual_model(100, 10, 10, 1, 1, overrides); + let style: VirtualListStyle = virtual_style(VirtualAxis.vertical, insets_all(0)); + let old_scroll: i64 = virtual_item_offset(model, style, 5); + let first: VirtualListResult = virtual_list_layout(model, size(100, 25), old_scroll, style); + let anchor: VirtualAnchor = virtual_anchor_at(model, style, 5, old_scroll); + expect(state, first.error == VirtualListError.none && first.first_visible == 5 && + first.placements.data[(first.first_visible - first.first_live) as i32].frame.height == 5 && + anchor.id == 15 && anchor.viewport_offset == 0, + "sparse variable extents preserve stable item identity and measured height revisions"); + overrides.data[0].extent = 50; overrides.data[0].revision = 2; + let changed: VirtualListModel = virtual_model(100, 10, 10, 1, 2, overrides); + let resolution: VirtualAnchorResolution = virtual_resolve_anchor( + changed, style, anchor, 25, old_scroll + ); + let second: VirtualListResult = virtual_list_layout( + changed, size(100, 25), resolution.scroll_offset, style + ); + expect(state, resolution.error == VirtualListError.none && resolution.changed == 1 && + resolution.scroll_offset == old_scroll + 20 && second.first_visible == 5 && + second.placements.data[(5 - second.first_live) as i32].frame.y == 0, + "anchor reconciliation absorbs size changes above the viewport without visible jumps"); + expect(state, second.layout_hash != first.layout_hash, + "extent revisions and changed geometry alter deterministic virtual identity"); + virtual_free(&second); virtual_free(&first); free[VirtualExtentOverride](&overrides); +} + +fn rtl_empty_and_failure_contract(state: *TestState) void { + let overrides: ArrayList[VirtualExtentOverride] = make[VirtualExtentOverride](1); + let model: VirtualListModel = virtual_model(3, 1, 10, 2, 0, overrides); + let ltr_style: VirtualListStyle = virtual_style(VirtualAxis.horizontal, insets_all(1)); + let ltr: VirtualListResult = virtual_list_layout(model, size(30, 20), 0, ltr_style); + let rtl_style: VirtualListStyle = ltr_style; rtl_style.direction = LayoutDirection.rtl; + let rtl: VirtualListResult = virtual_list_layout(model, size(30, 20), 0, rtl_style); + expect(state, ltr.error == VirtualListError.none && rtl.error == VirtualListError.none && + ltr.placements.data[0].frame.x == 1 && rtl.placements.data[0].frame.x == 19 && + rtl.placements.data[1].frame.x == 7 && rtl.layout_hash != ltr.layout_hash, + "horizontal virtualization mirrors logical item order under RTL"); + virtual_free(&rtl); virtual_free(<r); + + let empty_model: VirtualListModel = virtual_model(0, 1, 10, 0, 0, overrides); + let empty: VirtualListResult = virtual_list_layout(empty_model, size(40, 30), 50, + virtual_style(VirtualAxis.vertical, insets_all(3))); + expect(state, empty.error == VirtualListError.none && empty.placements.len == 0 && + empty.content.height == 6 && empty.scroll_offset == 0 && empty.first_visible == 0 - 1, + "empty virtual collections retain padding and canonical empty semantic ranges"); + virtual_free(&empty); + + let padded_model: VirtualListModel = virtual_model(1, 1, 10, 0, 0, overrides); + let padded_style: VirtualListStyle = virtual_style(VirtualAxis.vertical, + Insets{ .top = 50, .right = 0, .bottom = 0, .left = 0 }); + let padding_only: VirtualListResult = virtual_list_layout( + padded_model, size(40, 30), 0, padded_style + ); + expect(state, padding_only.error == VirtualListError.none && + padding_only.semantic_count == 1 && padding_only.placements.len == 0 && + padding_only.first_visible == 0 - 1, + "a viewport containing only leading padding reports no phantom visible item"); + virtual_free(&padding_only); + + let dense_model: VirtualListModel = virtual_model(1000, 1, 1, 0, 0, overrides); + let dense_style: VirtualListStyle = virtual_style(VirtualAxis.vertical, insets_all(0)); + dense_style.max_live_items = 10; + let limited: VirtualListResult = virtual_list_layout(dense_model, size(30, 100), 0, dense_style); + expect(state, limited.error == VirtualListError.live_limit_exceeded && limited.error_index == 0, + "live-node ceilings fail before unbounded viewport allocation"); + virtual_free(&limited); + free[VirtualExtentOverride](&overrides); + + let malformed_overrides: ArrayList[VirtualExtentOverride] = make[VirtualExtentOverride](2); + push[VirtualExtentOverride](&malformed_overrides, virtual_override(5, 10, 0)); + push[VirtualExtentOverride](&malformed_overrides, virtual_override(4, 10, 0)); + let malformed_model: VirtualListModel = virtual_model(10, 1, 10, 0, 0, malformed_overrides); + let malformed: VirtualListResult = virtual_list_layout(malformed_model, size(30, 30), 0, + virtual_style(VirtualAxis.vertical, insets_all(0))); + expect(state, malformed.error == VirtualListError.invalid_override && malformed.error_index == 1, + "unsorted sparse extent overrides fail at the exact record"); + virtual_free(&malformed); free[VirtualExtentOverride](&malformed_overrides); + + let huge_overrides: ArrayList[VirtualExtentOverride] = make[VirtualExtentOverride](1); + let huge_model: VirtualListModel = virtual_model(1000000000, 1, + unbounded_extent() / 4, 0, 0, huge_overrides); + let huge: VirtualListResult = virtual_list_layout(huge_model, size(30, 30), 0, + virtual_style(VirtualAxis.vertical, insets_all(0))); + expect(state, huge.error == VirtualListError.arithmetic_overflow, + "billion-row content extent overflow fails before multiplication or allocation"); + virtual_free(&huge); free[VirtualExtentOverride](&huge_overrides); + + let scroll_overrides: ArrayList[VirtualExtentOverride] = make[VirtualExtentOverride](1); + let scroll_model: VirtualListModel = virtual_model(2, 1, 10, 0, 0, scroll_overrides); + let bad_scroll: VirtualListResult = virtual_list_layout(scroll_model, size(30, 30), + unbounded_extent() * 2 + 1, virtual_style(VirtualAxis.vertical, insets_all(0))); + expect(state, bad_scroll.error == VirtualListError.invalid_scroll, + "out-of-contract scroll coordinates fail with an exact typed reason"); + virtual_free(&bad_scroll); free[VirtualExtentOverride](&scroll_overrides); +} + +fn property_contract(state: *TestState) void { + let overrides: ArrayList[VirtualExtentOverride] = make[VirtualExtentOverride](1); + let model: VirtualListModel = virtual_model(10000, 100, 8, 1, 3, overrides); + let style: VirtualListStyle = virtual_style(VirtualAxis.vertical, insets_all(1)); + let valid: i32 = 1; + let index: i64 = 0; + while (index < 1000) { + let offset: i64 = virtual_item_offset(model, style, index); + let result: VirtualListResult = virtual_list_layout(model, size(80, 64), offset, style); + if (result.error != VirtualListError.none || result.first_visible != index || + result.placements.len > 12 || result.examined_items > 48 || + result.placements.data[(index - result.first_live) as i32].id != 100 + index || + result.placements.data[(index - result.first_live) as i32].frame.y != 0) { + valid = 0; + } + virtual_free(&result); + index = index + 37; + } + expect(state, valid == 1, + "28-position virtual sweep preserves stable IDs exact anchors and logarithmic bounded work"); + free[VirtualExtentOverride](&overrides); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + scroll_contract(&state); + reveal_anchor_and_rtl_contract(&state); + million_row_contract(&state); + variable_anchor_contract(&state); + rtl_empty_and_failure_contract(&state); + property_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Scroll/virtual contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 7f2838a..54c9cd6 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -24,6 +24,8 @@ cd "$root" "$tmp/overlay-contract" "$znc" tests/grid_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/grid-contract" "$tmp/grid-contract" +"$znc" tests/scroll_virtual_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/scroll-virtual-contract" +"$tmp/scroll-virtual-contract" "$znc" tests/state_reconcile_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/state-reconcile-contract" "$tmp/state-reconcile-contract" "$znc" tests/semantics_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/semantics-contract" @@ -43,4 +45,4 @@ cd "$root" "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, semantics, Talkback, display lists, CPU raster, input, replay, and motion)\n' +printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, semantics, Talkback, display lists, CPU raster, input, replay, and motion)\n' From 54abb2b32ca8bbf1c66a815c1a2e11257df14eee Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 08:29:12 -0700 Subject: [PATCH 020/103] Add virtual Table Tree and recycling lifecycle --- README.md | 13 +- docs/architecture/measurement.md | 9 +- .../architecture/scroll-and-virtualization.md | 59 ++- docs/quality/release-gates.md | 7 + src/core/virtual_lifecycle.zag | 264 ++++++++++++ src/layout/virtual_table.zag | 403 ++++++++++++++++++ src/layout/virtual_tree.zag | 324 ++++++++++++++ src/replay/replay.zag | 79 +++- tests/virtual_collections_contract.zag | 325 ++++++++++++++ tools/test-headless.sh | 4 +- 10 files changed, 1472 insertions(+), 15 deletions(-) create mode 100644 src/core/virtual_lifecycle.zag create mode 100644 src/layout/virtual_table.zag create mode 100644 src/layout/virtual_tree.zag create mode 100644 tests/virtual_collections_contract.zag diff --git a/README.md b/README.md index a267657..f964d7d 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,8 @@ experimental. A retained intrinsic tree now aggregates leaf, row, column, and overlay size ranges, rejects invalid ownership and unstable same-revision measurement, resolves constraints with explicit rules and overflow, and records exact state-read layout causes. Typed environment values, reconciliation -cancellation, retained child lifecycle, replay serialization, Table and Tree -projection, and full breakpoint policy remain open; the corresponding Milestone +cancellation, general retained child lifecycle, replay serialization, editable +collection behavior, and full breakpoint policy remain open; the corresponding Milestone 2 checklist items are not complete. See the [measurement contract](docs/architecture/measurement.md). @@ -98,6 +98,15 @@ live placements and fewer than 64 examined records. This is correctness and bounded-work evidence, not a 120 Hz hardware result. See the [scroll and virtualization contract](docs/architecture/scroll-and-virtualization.md). +Table now composes bounded row and column virtualization with pinned logical +columns, exact resize consumption, composite stable cell IDs, RTL placement, +and hard cell residency limits. Tree validates preorder ownership and expansion +before projecting collapsed descendants into the virtual range. A retained +recycling store preserves offscreen focus and restores the exact instance and +generation when its logical ID returns. These identities participate in replay. +Sorting, selection, editing, drag reordering, component semantics, and native +interaction remain open. + The semantics slice owns copied names and values, stable keys, explicit action capabilities, deterministic focus order, live-region state, ranges, selection, and text-navigation bounds. Invalid parents, duplicate IDs and focus order, diff --git a/docs/architecture/measurement.md b/docs/architecture/measurement.md index cd39dca..f75e936 100644 --- a/docs/architecture/measurement.md +++ b/docs/architecture/measurement.md @@ -110,7 +110,8 @@ The following remain before `G2-CONSTRAINTS` can complete: - serialized measurement and invalidation evidence; - property and fuzz coverage across the complete component layout matrix. -Table and Tree projection, recycled retained-node lifecycle, and platform-driven -adaptive conformance remain separate Flex and virtualization work. Grid, -Overlay, Scroll, and Virtual List now have experimental placement contracts; -their existence does not complete the full component layout matrix. +Editable Table and Tree behavior, general retained-node lifecycle, and +platform-driven adaptive conformance remain separate Flex and virtualization +work. Grid, Overlay, Scroll, Virtual List, Table projection, Tree projection, +and bounded recycling now have experimental contracts; their existence does +not complete the full component layout matrix. diff --git a/docs/architecture/scroll-and-virtualization.md b/docs/architecture/scroll-and-virtualization.md index ba671bb..d7f6acf 100644 --- a/docs/architecture/scroll-and-virtualization.md +++ b/docs/architecture/scroll-and-virtualization.md @@ -54,16 +54,61 @@ anchoring a different row. Horizontal lists retain logical order and mirror placement in RTL. Padding remains physical. Vertical collection direction does not reorder semantic IDs. +## Table projection + +Virtual Table composes the million-row model with up to 256 stable columns. +Logical-leading pinned columns remain resident while the remaining columns +scroll and overscan independently. Header and body cells carry a collision-free +composite identity of table, row, column, and header state; a single lossy hash +is never presented as the authoritative cell ID. + +Table results expose semantic row and column counts, visible and live row +ranges, visible scrolling columns, pinned count, both scroll maxima, and every +live cell frame. `max_live_cells` fails before allocation. Column resize retains +minimum and maximum bounds, reports consumed and unconsumed delta, and advances +both column and model revisions only when width changes. Pinned columns must be +one contiguous logical-leading region and mirror physically under RTL. + +## Tree projection + +Tree input is canonical preorder with stable ID, explicit parent ID, depth, +child capability, expansion state, extent, and revision. Projection validates +duplicate IDs, depth jumps, parent ownership, and leaf ownership before hiding +collapsed descendants. The visible projection retains source index and parent +identity, then uses the same bounded virtual range engine as List. + +Indentation applies to the content frame at logical start while the complete +row frame remains available for selection and hit testing. RTL moves indentation +to the right without changing semantic order. An open-addressed stable-ID index +keeps duplicate validation linear; the current retained projection remains +capped at 16,384 source nodes as an explicit allocation bound. + +## Focus and recycling lifecycle + +`VirtualRetainedStore` separates logical IDs from recyclable retained instances. +Evicted nonfocused entries may be rebound with an incremented generation; +logical identity never inherits the previous item's generation. A focused +offscreen entry is pinned against recycling. When it returns, the exact instance +and generation are restored. Clearing focus makes it recyclable again. + +Residency has an explicit capacity. Live IDs plus an offscreen focus pin must +fit or reconciliation fails before mutation. Duplicate IDs and unknown focus +requests also fail. Identical live membership performs no revision, timestamp, +or identity churn. + ## Ownership and evidence boundary Call `virtual_free` once for every layout result, including failures. The model borrows its sparse override list; its creator owns that list. Replay mixes scroll and virtual-list identity with Flex, Grid, and Overlay identity. -The executable suite proves a one-million-row mid-list query with nine live -placements and fewer than 64 examined records, sparse variable extents, anchor -stability, RTL horizontal placement, exact empty and padding-only ranges, -bounded allocation failure, malformed input, overflow rejection, and a -multi-position property sweep. Stopwatch timing in CI is not a 120 Hz claim. -Table and Tree projection, focus retention across eviction, recycling lifecycle, -native input, semantic adapters, and reference-hardware performance remain open. +The executable suites prove a one-million-row mid-list query with nine live +placements and fewer than 64 examined records, a million-row two-axis Table, +collapsed and expanded Tree projections, sparse variable extents, anchor and +offscreen-focus stability, RTL placement, exact empty and padding-only ranges, +bounded allocation failures, malformed input, overflow rejection, and scroll, +expansion, and 100-window recycling sweeps. Replay incorporates List, Table, +Tree, scroll, and lifecycle identities. Stopwatch timing in CI is not a 120 Hz +claim. Native input, semantic adapters, editable cells, sorting, selection, +drag reordering, general Tree indexing, and reference-hardware performance +remain open. diff --git a/docs/quality/release-gates.md b/docs/quality/release-gates.md index bff220d..5efdbc3 100644 --- a/docs/quality/release-gates.md +++ b/docs/quality/release-gates.md @@ -49,6 +49,13 @@ fail-closed allocation ceilings, focus restoration, and no linear work per scroll frame. A million-row range proof is necessary but does not replace the 120 Hz reference-hardware and ten-minute stall gates. +Table gates additionally cover two-axis residency, pinned-column occlusion, +resize bounds, composite cell identity, keyboard grids, and editable-cell +focus. Tree gates cover expansion, collapse, parent relationships, logical +indentation, selection, and focus restoration. Recycling cannot transfer a +logical generation between IDs, and offscreen focused nodes remain protected +until focus explicitly moves or clears. + Zagkit Talkback conformance drives canonical applications by stable ID and checks discovery, query, action, wait, assertion, screenshot, timeline, capability, snapshot, and replay behavior. Pixel fallback is separately diff --git a/src/core/virtual_lifecycle.zag b/src/core/virtual_lifecycle.zag new file mode 100644 index 0000000..c7ee5ee --- /dev/null +++ b/src/core/virtual_lifecycle.zag @@ -0,0 +1,264 @@ +@import("std:list") + +enum VirtualLifecycleError { + none, + invalid_capacity, + too_many_live_nodes, + invalid_id, + duplicate_id, + unknown_focus, + no_recyclable_node, +} + +struct VirtualRetainedEntry { + logical_id: i64, + instance_id: i64, + generation: i64, + live: i32, + focused: i32, + last_seen_revision: i64, +} + +struct VirtualRetainedStore { + entries: ArrayList[VirtualRetainedEntry], + capacity: i32, + next_instance_id: i64, + revision: i64, + focused_id: i64, +} + +struct VirtualLifecycleResult { + inserted: i32, + reused: i32, + rebound: i32, + evicted: i32, + live: i32, + resident: i32, + focus_restored: i32, + lifecycle_hash: i64, + error: VirtualLifecycleError, + error_index: i32, +} + +fn virtual_store_make(capacity: i32) VirtualRetainedStore { + let safe_capacity: i32 = capacity; + if (safe_capacity < 1 || safe_capacity > 4096) { safe_capacity = 1; } + return VirtualRetainedStore{ + .entries = make[VirtualRetainedEntry](safe_capacity), + .capacity = capacity, + .next_instance_id = 1, + .revision = 0, + .focused_id = 0, + }; +} + +fn virtual_store_free(store: *VirtualRetainedStore) void { + free[VirtualRetainedEntry](&store.*.entries); +} + +fn virtual_lifecycle_mix(hash: i64, value: i64) i64 { + return (hash * 1099511628211) ^ value; +} + +fn virtual_store_find(store: VirtualRetainedStore, logical_id: i64) i32 { + let i: i32 = 0; + while (i < store.entries.len) { + if (store.entries.data[i].logical_id == logical_id) { return i; } + i = i + 1; + } + return 0 - 1; +} + +fn virtual_ids_contains(ids: ArrayList[i64], logical_id: i64) i32 { + let i: i32 = 0; + while (i < ids.len) { + if (ids.data[i] == logical_id) { return 1; } + i = i + 1; + } + return 0; +} + +fn virtual_store_hash(store: VirtualRetainedStore) i64 { + let hash: i64 = virtual_lifecycle_mix(virtual_lifecycle_mix(17, store.capacity as i64), store.revision); + hash = virtual_lifecycle_mix(hash, store.next_instance_id); + hash = virtual_lifecycle_mix(hash, store.focused_id); + hash = virtual_lifecycle_mix(hash, store.entries.len as i64); + let i: i32 = 0; + while (i < store.entries.len) { + let entry: VirtualRetainedEntry = store.entries.data[i]; + hash = virtual_lifecycle_mix(hash, entry.logical_id); + hash = virtual_lifecycle_mix(hash, entry.instance_id); + hash = virtual_lifecycle_mix(hash, entry.generation); + hash = virtual_lifecycle_mix(hash, entry.live as i64); + hash = virtual_lifecycle_mix(hash, entry.focused as i64); + hash = virtual_lifecycle_mix(hash, entry.last_seen_revision); + i = i + 1; + } + return hash; +} + +fn virtual_lifecycle_error(store: VirtualRetainedStore, error: VirtualLifecycleError, + index: i32) VirtualLifecycleResult { + return VirtualLifecycleResult{ + .inserted = 0, .reused = 0, .rebound = 0, .evicted = 0, + .live = 0, .resident = store.entries.len, .focus_restored = 0, + .lifecycle_hash = virtual_lifecycle_mix(virtual_store_hash(store), error as i64), + .error = error, .error_index = index, + }; +} + +fn virtual_store_focus(store: *VirtualRetainedStore, logical_id: i64) VirtualLifecycleError { + if (logical_id == 0) { + if (store.*.focused_id == 0) { return VirtualLifecycleError.none; } + let old_index: i32 = virtual_store_find(store.*, store.*.focused_id); + if (old_index >= 0) { store.*.entries.data[old_index].focused = 0; } + store.*.focused_id = 0; + store.*.revision = store.*.revision + 1; + return VirtualLifecycleError.none; + } + let index: i32 = virtual_store_find(store.*, logical_id); + if (index < 0) { return VirtualLifecycleError.unknown_focus; } + if (store.*.focused_id == logical_id) { return VirtualLifecycleError.none; } + let old_index: i32 = virtual_store_find(store.*, store.*.focused_id); + if (old_index >= 0) { store.*.entries.data[old_index].focused = 0; } + store.*.focused_id = logical_id; + store.*.entries.data[index].focused = 1; + store.*.revision = store.*.revision + 1; + return VirtualLifecycleError.none; +} + +fn virtual_store_reconcile(store: *VirtualRetainedStore, + live_ids: ArrayList[i64]) VirtualLifecycleResult { + if (store.*.capacity < 1 || store.*.capacity > 4096) { + return virtual_lifecycle_error(store.*, VirtualLifecycleError.invalid_capacity, 0 - 1); + } + if (live_ids.len > store.*.capacity) { + return virtual_lifecycle_error(store.*, VirtualLifecycleError.too_many_live_nodes, 0 - 1); + } + if (store.*.focused_id != 0 && virtual_ids_contains(live_ids, store.*.focused_id) == 0 && + live_ids.len >= store.*.capacity) { + return virtual_lifecycle_error(store.*, VirtualLifecycleError.too_many_live_nodes, 0 - 1); + } + let i: i32 = 0; + while (i < live_ids.len) { + if (live_ids.data[i] <= 0) { + return virtual_lifecycle_error(store.*, VirtualLifecycleError.invalid_id, i); + } + let j: i32 = 0; + while (j < i) { + if (live_ids.data[j] == live_ids.data[i]) { + return virtual_lifecycle_error(store.*, VirtualLifecycleError.duplicate_id, i); + } + j = j + 1; + } + i = i + 1; + } + + let current_live: i32 = 0; + let identical_membership: i32 = 1; + i = 0; + while (i < store.*.entries.len) { + if (store.*.entries.data[i].live != 0) { + current_live = current_live + 1; + if (virtual_ids_contains(live_ids, store.*.entries.data[i].logical_id) == 0) { + identical_membership = 0; + } + } + i = i + 1; + } + if (current_live != live_ids.len) { identical_membership = 0; } + if (identical_membership != 0) { + return VirtualLifecycleResult{ + .inserted = 0, .reused = live_ids.len, .rebound = 0, .evicted = 0, + .live = live_ids.len, .resident = store.*.entries.len, + .focus_restored = 0, .lifecycle_hash = virtual_store_hash(store.*), + .error = VirtualLifecycleError.none, .error_index = 0 - 1, + }; + } + + let was_live: ArrayList[i32] = make[i32](store.*.entries.len); + let evicted: i32 = 0; + i = 0; + while (i < store.*.entries.len) { + push[i32](&was_live, store.*.entries.data[i].live); + if (store.*.entries.data[i].live != 0 && + virtual_ids_contains(live_ids, store.*.entries.data[i].logical_id) == 0) { + evicted = evicted + 1; + } + store.*.entries.data[i].live = 0; + i = i + 1; + } + + let inserted: i32 = 0; + let reused: i32 = 0; + let rebound: i32 = 0; + let focus_restored: i32 = 0; + let next_revision: i64 = store.*.revision + 1; + i = 0; + while (i < live_ids.len) { + let logical_id: i64 = live_ids.data[i]; + let found: i32 = virtual_store_find(store.*, logical_id); + if (found >= 0) { + if (store.*.entries.data[found].focused != 0 && was_live.data[found] == 0) { + focus_restored = focus_restored + 1; + } + store.*.entries.data[found].live = 1; + store.*.entries.data[found].last_seen_revision = next_revision; + reused = reused + 1; + } else if (store.*.entries.len < store.*.capacity) { + let instance_id: i64 = store.*.next_instance_id; + store.*.next_instance_id = store.*.next_instance_id + 1; + push[VirtualRetainedEntry](&store.*.entries, VirtualRetainedEntry{ + .logical_id = logical_id, .instance_id = instance_id, .generation = 0, + .live = 1, .focused = 0, .last_seen_revision = next_revision, + }); + inserted = inserted + 1; + } else { + let candidate: i32 = 0 - 1; + let oldest: i64 = 0; + let j: i32 = 0; + while (j < store.*.entries.len) { + let entry: VirtualRetainedEntry = store.*.entries.data[j]; + if (entry.live == 0 && entry.focused == 0 && + virtual_ids_contains(live_ids, entry.logical_id) == 0 && + (candidate < 0 || entry.last_seen_revision < oldest)) { + candidate = j; + oldest = entry.last_seen_revision; + } + j = j + 1; + } + if (candidate < 0) { + i = 0; + while (i < store.*.entries.len) { + store.*.entries.data[i].live = was_live.data[i]; + i = i + 1; + } + free[i32](&was_live); + return virtual_lifecycle_error(store.*, VirtualLifecycleError.no_recyclable_node, i); + } + store.*.entries.data[candidate].logical_id = logical_id; + store.*.entries.data[candidate].generation = store.*.entries.data[candidate].generation + 1; + store.*.entries.data[candidate].live = 1; + store.*.entries.data[candidate].last_seen_revision = next_revision; + rebound = rebound + 1; + } + i = i + 1; + } + + let membership_changed: i32 = (inserted > 0 || rebound > 0 || evicted > 0) as i32; + i = 0; + while (i < was_live.len) { + if (was_live.data[i] == 0 && store.*.entries.data[i].live != 0) { + membership_changed = 1; + } + i = i + 1; + } + if (membership_changed != 0) { store.*.revision = next_revision; } + free[i32](&was_live); + return VirtualLifecycleResult{ + .inserted = inserted, .reused = reused, .rebound = rebound, .evicted = evicted, + .live = live_ids.len, .resident = store.*.entries.len, + .focus_restored = focus_restored, .lifecycle_hash = virtual_store_hash(store.*), + .error = VirtualLifecycleError.none, .error_index = 0 - 1, + }; +} diff --git a/src/layout/virtual_table.zag b/src/layout/virtual_table.zag new file mode 100644 index 0000000..d50f16c --- /dev/null +++ b/src/layout/virtual_table.zag @@ -0,0 +1,403 @@ +@import("std:list") +@import("../core/geometry.zag") +@import("common.zag") +@import("virtual_list.zag") + +enum VirtualTableError { + none, + invalid_model, + invalid_column, + duplicate_column, + invalid_pinned_order, + invalid_style, + invalid_viewport, + row_layout_error, + cell_limit_exceeded, + unknown_column, + column_not_resizable, +} + +struct VirtualTableColumn { + id: i64, + width: i64, + minimum: i64, + maximum: i64, + pinned: i32, + resizable: i32, + revision: i64, +} + +struct VirtualTableModel { + id: i64, + rows: VirtualListModel, + columns: ArrayList[VirtualTableColumn], + revision: i64, +} + +struct VirtualTableStyle { + header_extent: i64, + column_gap: i64, + padding: Insets, + direction: LayoutDirection, + row_overscan_before: i32, + row_overscan_after: i32, + column_overscan: i32, + max_live_cells: i32, +} + +struct VirtualTableCellId { + table_id: i64, + row_id: i64, + column_id: i64, + header: i32, +} + +struct VirtualTableCell { + id: VirtualTableCellId, + row_id: i64, + column_id: i64, + row_index: i64, + column_index: i32, + frame: Rect, + header: i32, + pinned: i32, + row_revision: i64, + column_revision: i64, +} + +struct VirtualTableResult { + cells: ArrayList[VirtualTableCell], + viewport: Size, + content: Size, + vertical_scroll: i64, + horizontal_scroll: i64, + max_vertical_scroll: i64, + max_horizontal_scroll: i64, + first_visible_row: i64, + last_visible_row: i64, + first_live_row: i64, + last_live_row: i64, + first_visible_column: i32, + last_visible_column: i32, + semantic_rows: i64, + semantic_columns: i32, + pinned_columns: i32, + layout_hash: i64, + error: VirtualTableError, + error_index: i32, +} + +struct VirtualTableResizeResult { + column_id: i64, + old_width: i64, + width: i64, + consumed: i64, + unconsumed: i64, + revision: i64, + changed: i32, + error: VirtualTableError, +} + +fn virtual_table_column(id: i64, width: i64, minimum: i64, + maximum: i64) VirtualTableColumn { + return VirtualTableColumn{ + .id = id, .width = width, .minimum = minimum, .maximum = maximum, + .pinned = 0, .resizable = 1, .revision = 0, + }; +} + +fn virtual_table_model(id: i64, rows: VirtualListModel, + columns: ArrayList[VirtualTableColumn], revision: i64) VirtualTableModel { + return VirtualTableModel{ .id = id, .rows = rows, .columns = columns, .revision = revision }; +} + +fn virtual_table_style(header_extent: i64, column_gap: i64, + padding: Insets) VirtualTableStyle { + return VirtualTableStyle{ + .header_extent = header_extent, .column_gap = column_gap, .padding = padding, + .direction = LayoutDirection.ltr, + .row_overscan_before = 2, .row_overscan_after = 2, + .column_overscan = 1, .max_live_cells = 4096, + }; +} + +fn virtual_table_free(result: *VirtualTableResult) void { + free[VirtualTableCell](&result.*.cells); +} + +fn virtual_table_mix(hash: i64, value: i64) i64 { return (hash * 1099511628211) ^ value; } + +fn virtual_table_error_result(error: VirtualTableError, index: i32) VirtualTableResult { + let result: VirtualTableResult = VirtualTableResult{ + .cells = make[VirtualTableCell](1), .viewport = size(0, 0), .content = size(0, 0), + .vertical_scroll = 0, .horizontal_scroll = 0, + .max_vertical_scroll = 0, .max_horizontal_scroll = 0, + .first_visible_row = 0 - 1, .last_visible_row = 0 - 1, + .first_live_row = 0 - 1, .last_live_row = 0 - 1, + .first_visible_column = 0 - 1, .last_visible_column = 0 - 1, + .semantic_rows = 0, .semantic_columns = 0, .pinned_columns = 0, + .layout_hash = virtual_table_mix(virtual_table_mix(17, error as i64), index as i64), + .error = error, .error_index = index, + }; + return result; +} + +fn virtual_table_column_valid(column: VirtualTableColumn) i32 { + return (column.id > 0 && column.minimum > 0 && column.width >= column.minimum && + column.maximum >= column.width && column.maximum <= unbounded_extent() / 4 && + (column.pinned == 0 || column.pinned == 1) && + (column.resizable == 0 || column.resizable == 1) && column.revision >= 0) as i32; +} + +fn virtual_table_style_valid(style: VirtualTableStyle) i32 { + return (style.header_extent > 0 && style.header_extent <= unbounded_extent() / 4 && + style.column_gap >= 0 && style.column_gap <= unbounded_extent() / 4 && + virtual_insets_valid(style.padding) == 1 && + style.row_overscan_before >= 0 && style.row_overscan_before <= 4096 && + style.row_overscan_after >= 0 && style.row_overscan_after <= 4096 && + style.column_overscan >= 0 && style.column_overscan <= 256 && + style.max_live_cells > 0 && style.max_live_cells <= 65536) as i32; +} + +fn virtual_table_cell_id(table_id: i64, row_id: i64, column_id: i64, + header: i32) VirtualTableCellId { + return VirtualTableCellId{ + .table_id = table_id, .row_id = row_id, + .column_id = column_id, .header = header, + }; +} + +fn virtual_table_resize(model: *VirtualTableModel, column_id: i64, + delta: i64) VirtualTableResizeResult { + let index: i32 = 0 - 1; + let i: i32 = 0; + while (i < model.*.columns.len) { + if (model.*.columns.data[i].id == column_id) { index = i; break; } + i = i + 1; + } + if (index < 0) { + return VirtualTableResizeResult{ + .column_id = column_id, .old_width = 0, .width = 0, + .consumed = 0, .unconsumed = delta, .revision = model.*.revision, + .changed = 0, .error = VirtualTableError.unknown_column, + }; + } + let column: VirtualTableColumn = model.*.columns.data[index]; + if (column.resizable == 0) { + return VirtualTableResizeResult{ + .column_id = column_id, .old_width = column.width, .width = column.width, + .consumed = 0, .unconsumed = delta, .revision = model.*.revision, + .changed = 0, .error = VirtualTableError.column_not_resizable, + }; + } + if (delta < 0 - unbounded_extent() || delta > unbounded_extent()) { + return VirtualTableResizeResult{ + .column_id = column_id, .old_width = column.width, .width = column.width, + .consumed = 0, .unconsumed = delta, .revision = model.*.revision, + .changed = 0, .error = VirtualTableError.invalid_column, + }; + } + let width: i64 = clamp_i64(column.width + delta, column.minimum, column.maximum); + let consumed: i64 = width - column.width; + let changed: i32 = (consumed != 0) as i32; + if (changed != 0) { + model.*.columns.data[index].width = width; + model.*.columns.data[index].revision = model.*.columns.data[index].revision + 1; + model.*.revision = model.*.revision + 1; + } + return VirtualTableResizeResult{ + .column_id = column_id, .old_width = column.width, .width = width, + .consumed = consumed, .unconsumed = delta - consumed, + .revision = model.*.revision, .changed = changed, .error = VirtualTableError.none, + }; +} + +fn virtual_table_layout(model: VirtualTableModel, viewport: Size, + vertical_scroll: i64, horizontal_scroll: i64, + style: VirtualTableStyle) VirtualTableResult { + if (model.id <= 0 || model.revision < 0 || model.columns.len <= 0 || model.columns.len > 256 || + virtual_model_error(model.rows) != VirtualListError.none) { + return virtual_table_error_result(VirtualTableError.invalid_model, 0 - 1); + } + let pinned_count: i32 = 0; + let seen_unpinned: i32 = 0; + let i: i32 = 0; + while (i < model.columns.len) { + let column: VirtualTableColumn = model.columns.data[i]; + if (virtual_table_column_valid(column) == 0) { + return virtual_table_error_result(VirtualTableError.invalid_column, i); + } + let j: i32 = 0; + while (j < i) { + if (model.columns.data[j].id == column.id) { + return virtual_table_error_result(VirtualTableError.duplicate_column, i); + } + j = j + 1; + } + if (column.pinned != 0) { + if (seen_unpinned != 0) { + return virtual_table_error_result(VirtualTableError.invalid_pinned_order, i); + } + pinned_count = pinned_count + 1; + } else { seen_unpinned = 1; } + i = i + 1; + } + if (virtual_table_style_valid(style) == 0) { + return virtual_table_error_result(VirtualTableError.invalid_style, 0 - 1); + } + if (viewport.width <= 0 || viewport.height <= 0 || + viewport.width > unbounded_extent() || viewport.height > unbounded_extent() || + viewport.width <= style.padding.left + style.padding.right || + viewport.height <= style.padding.top + style.padding.bottom + style.header_extent) { + return virtual_table_error_result(VirtualTableError.invalid_viewport, 0 - 1); + } + let inner_width: i64 = viewport.width - style.padding.left - style.padding.right; + let body_height: i64 = viewport.height - style.padding.top - style.padding.bottom - style.header_extent; + let pinned_width: i64 = 0; + i = 0; + while (i < pinned_count) { + pinned_width = pinned_width + model.columns.data[i].width; + if (i + 1 < model.columns.len) { pinned_width = pinned_width + style.column_gap; } + i = i + 1; + } + if (pinned_width >= inner_width) { + return virtual_table_error_result(VirtualTableError.invalid_viewport, pinned_count - 1); + } + let scroll_viewport_width: i64 = inner_width - pinned_width; + let unpinned_content_width: i64 = 0; + i = pinned_count; + while (i < model.columns.len) { + unpinned_content_width = unpinned_content_width + model.columns.data[i].width; + if (i + 1 < model.columns.len) { unpinned_content_width = unpinned_content_width + style.column_gap; } + if (unpinned_content_width > unbounded_extent()) { + return virtual_table_error_result(VirtualTableError.invalid_column, i); + } + i = i + 1; + } + let max_horizontal: i64 = unpinned_content_width - scroll_viewport_width; + if (max_horizontal < 0) { max_horizontal = 0; } + let horizontal: i64 = clamp_i64(horizontal_scroll, 0, max_horizontal); + + let row_style: VirtualListStyle = virtual_style(VirtualAxis.vertical, insets_all(0)); + row_style.overscan_before = style.row_overscan_before; + row_style.overscan_after = style.row_overscan_after; + row_style.max_live_items = 4096; + let row_layout: VirtualListResult = virtual_list_layout( + model.rows, size(inner_width, body_height), vertical_scroll, row_style + ); + if (row_layout.error != VirtualListError.none) { + let error_index: i32 = row_layout.error_index as i32; + virtual_free(&row_layout); + return virtual_table_error_result(VirtualTableError.row_layout_error, error_index); + } + + let visible_columns: ArrayList[i32] = make[i32](model.columns.len); + i = 0; + while (i < pinned_count) { push[i32](&visible_columns, i); i = i + 1; } + let first_scrolling: i32 = 0 - 1; + let last_scrolling: i32 = 0 - 1; + let logical_offset: i64 = 0; + i = pinned_count; + while (i < model.columns.len) { + let width: i64 = model.columns.data[i].width; + if (logical_offset + width > horizontal && + logical_offset < horizontal + scroll_viewport_width) { + if (first_scrolling < 0) { first_scrolling = i; } + last_scrolling = i; + } + logical_offset = logical_offset + width; + if (i + 1 < model.columns.len) { logical_offset = logical_offset + style.column_gap; } + i = i + 1; + } + let live_column_start: i32 = first_scrolling - style.column_overscan; + if (live_column_start < pinned_count) { live_column_start = pinned_count; } + let live_column_end: i32 = last_scrolling + style.column_overscan; + if (live_column_end >= model.columns.len) { live_column_end = model.columns.len - 1; } + if (first_scrolling >= 0) { + i = live_column_start; + while (i <= live_column_end) { push[i32](&visible_columns, i); i = i + 1; } + } + let required_cells: i64 = visible_columns.len as i64; + required_cells = required_cells * (row_layout.placements.len as i64 + 1); + if (required_cells > style.max_live_cells as i64) { + free[i32](&visible_columns); virtual_free(&row_layout); + return virtual_table_error_result(VirtualTableError.cell_limit_exceeded, required_cells as i32); + } + + let column_offsets: ArrayList[i64] = make[i64](model.columns.len); + let pinned_cursor: i64 = 0; + let unpinned_cursor: i64 = 0; + i = 0; + while (i < model.columns.len) { + if (i < pinned_count) { + push[i64](&column_offsets, pinned_cursor); + pinned_cursor = pinned_cursor + model.columns.data[i].width + style.column_gap; + } else { + push[i64](&column_offsets, pinned_width + unpinned_cursor - horizontal); + unpinned_cursor = unpinned_cursor + model.columns.data[i].width + style.column_gap; + } + i = i + 1; + } + + let cells: ArrayList[VirtualTableCell] = make[VirtualTableCell](required_cells as i32); + i = 0; + while (i < visible_columns.len) { + let column_index: i32 = visible_columns.data[i]; + let column: VirtualTableColumn = model.columns.data[column_index]; + let x: i64 = style.padding.left + column_offsets.data[column_index]; + if (style.direction == LayoutDirection.rtl) { + x = viewport.width - style.padding.right - column_offsets.data[column_index] - column.width; + } + push[VirtualTableCell](&cells, VirtualTableCell{ + .id = virtual_table_cell_id(model.id, 0, column.id, 1), + .row_id = 0, .column_id = column.id, .row_index = 0 - 1, + .column_index = column_index, + .frame = rect(x, style.padding.top, column.width, style.header_extent), + .header = 1, .pinned = column.pinned, + .row_revision = model.revision, .column_revision = column.revision, + }); + let r: i32 = 0; + while (r < row_layout.placements.len) { + let row: VirtualPlacement = row_layout.placements.data[r]; + push[VirtualTableCell](&cells, VirtualTableCell{ + .id = virtual_table_cell_id(model.id, row.id, column.id, 0), + .row_id = row.id, .column_id = column.id, .row_index = row.index, + .column_index = column_index, + .frame = rect(x, style.padding.top + style.header_extent + row.frame.y, + column.width, row.frame.height), + .header = 0, .pinned = column.pinned, + .row_revision = row.extent_revision, .column_revision = column.revision, + }); + r = r + 1; + } + i = i + 1; + } + let content_width: i64 = pinned_width + unpinned_content_width + style.padding.left + style.padding.right; + let content_height: i64 = row_layout.content.height + style.header_extent + + style.padding.top + style.padding.bottom; + let result: VirtualTableResult = VirtualTableResult{ + .cells = cells, .viewport = viewport, .content = size(content_width, content_height), + .vertical_scroll = row_layout.scroll_offset, .horizontal_scroll = horizontal, + .max_vertical_scroll = row_layout.max_scroll, .max_horizontal_scroll = max_horizontal, + .first_visible_row = row_layout.first_visible, .last_visible_row = row_layout.last_visible, + .first_live_row = row_layout.first_live, .last_live_row = row_layout.last_live, + .first_visible_column = first_scrolling, .last_visible_column = last_scrolling, + .semantic_rows = model.rows.count, .semantic_columns = model.columns.len, + .pinned_columns = pinned_count, .layout_hash = virtual_table_mix(row_layout.layout_hash, model.revision), + .error = VirtualTableError.none, .error_index = 0 - 1, + }; + i = 0; + while (i < result.cells.len) { + result.layout_hash = virtual_table_mix(result.layout_hash, result.cells.data[i].id.table_id); + result.layout_hash = virtual_table_mix(result.layout_hash, result.cells.data[i].id.row_id); + result.layout_hash = virtual_table_mix(result.layout_hash, result.cells.data[i].id.column_id); + result.layout_hash = virtual_table_mix(result.layout_hash, result.cells.data[i].id.header as i64); + result.layout_hash = virtual_table_mix(result.layout_hash, result.cells.data[i].frame.x); + result.layout_hash = virtual_table_mix(result.layout_hash, result.cells.data[i].frame.y); + result.layout_hash = virtual_table_mix(result.layout_hash, result.cells.data[i].frame.width); + result.layout_hash = virtual_table_mix(result.layout_hash, result.cells.data[i].frame.height); + i = i + 1; + } + free[i64](&column_offsets); free[i32](&visible_columns); virtual_free(&row_layout); + return result; +} diff --git a/src/layout/virtual_tree.zag b/src/layout/virtual_tree.zag new file mode 100644 index 0000000..eecdc27 --- /dev/null +++ b/src/layout/virtual_tree.zag @@ -0,0 +1,324 @@ +@import("std:list") +@import("../core/geometry.zag") +@import("common.zag") +@import("virtual_list.zag") + +enum VirtualTreeError { + none, + too_many_nodes, + invalid_node, + duplicate_id, + invalid_depth, + invalid_parent, + invalid_style, + layout_error, +} + +struct VirtualTreeNode { + id: i64, + parent_id: i64, + depth: i32, + has_children: i32, + expanded: i32, + extent: i64, + revision: i64, +} + +struct VirtualTreeVisibleNode { + id: i64, + parent_id: i64, + source_index: i32, + depth: i32, + has_children: i32, + expanded: i32, + extent: i64, + revision: i64, +} + +struct VirtualTreeProjection { + nodes: ArrayList[VirtualTreeVisibleNode], + source_count: i32, + revision: i64, + projection_hash: i64, + error: VirtualTreeError, + error_index: i32, +} + +struct VirtualTreeStyle { + row_extent: i64, + row_gap: i64, + indent: i64, + padding: Insets, + direction: LayoutDirection, + overscan_before: i32, + overscan_after: i32, + max_live_nodes: i32, +} + +struct VirtualTreePlacement { + id: i64, + parent_id: i64, + source_index: i32, + visible_index: i64, + depth: i32, + has_children: i32, + expanded: i32, + frame: Rect, + content_frame: Rect, + revision: i64, +} + +struct VirtualTreeResult { + placements: ArrayList[VirtualTreePlacement], + viewport: Size, + content: Size, + scroll_offset: i64, + max_scroll: i64, + first_visible: i64, + last_visible: i64, + first_live: i64, + last_live: i64, + semantic_count: i64, + source_count: i32, + layout_hash: i64, + error: VirtualTreeError, + error_index: i32, +} + +fn virtual_tree_node(id: i64, parent_id: i64, depth: i32, + has_children: i32, expanded: i32, extent: i64, revision: i64) VirtualTreeNode { + return VirtualTreeNode{ + .id = id, .parent_id = parent_id, .depth = depth, + .has_children = has_children, .expanded = expanded, + .extent = extent, .revision = revision, + }; +} + +fn virtual_tree_style(row_extent: i64, row_gap: i64, indent: i64, + padding: Insets) VirtualTreeStyle { + return VirtualTreeStyle{ + .row_extent = row_extent, .row_gap = row_gap, .indent = indent, + .padding = padding, .direction = LayoutDirection.ltr, + .overscan_before = 2, .overscan_after = 2, .max_live_nodes = 512, + }; +} + +fn virtual_tree_mix(hash: i64, value: i64) i64 { return (hash * 1099511628211) ^ value; } + +fn virtual_tree_projection_free(projection: *VirtualTreeProjection) void { + free[VirtualTreeVisibleNode](&projection.*.nodes); +} + +fn virtual_tree_free(result: *VirtualTreeResult) void { + free[VirtualTreePlacement](&result.*.placements); +} + +fn virtual_tree_projection_error(error: VirtualTreeError, index: i32, + source_count: i32) VirtualTreeProjection { + let result: VirtualTreeProjection = VirtualTreeProjection{ + .nodes = make[VirtualTreeVisibleNode](1), .source_count = source_count, + .revision = 0, .projection_hash = 17, + .error = error, .error_index = index, + }; + result.projection_hash = virtual_tree_mix(virtual_tree_mix(17, error as i64), index as i64); + return result; +} + +fn virtual_tree_result_error(error: VirtualTreeError, index: i32) VirtualTreeResult { + let result: VirtualTreeResult = VirtualTreeResult{ + .placements = make[VirtualTreePlacement](1), + .viewport = size(0, 0), .content = size(0, 0), + .scroll_offset = 0, .max_scroll = 0, + .first_visible = 0 - 1, .last_visible = 0 - 1, + .first_live = 0 - 1, .last_live = 0 - 1, + .semantic_count = 0, .source_count = 0, .layout_hash = 17, + .error = error, .error_index = index, + }; + result.layout_hash = virtual_tree_mix(virtual_tree_mix(17, error as i64), index as i64); + return result; +} + +fn virtual_tree_node_valid(node: VirtualTreeNode) i32 { + return (node.id > 0 && node.parent_id >= 0 && node.depth >= 0 && node.depth <= 512 && + (node.has_children == 0 || node.has_children == 1) && + (node.expanded == 0 || node.expanded == 1) && + (node.has_children == 1 || node.expanded == 0) && + node.extent > 0 && node.extent <= unbounded_extent() / 4 && node.revision >= 0) as i32; +} + +fn virtual_tree_seen_insert(seen: *ArrayList[i64], id: i64) i32 { + let index: i32 = (id % seen.*.len as i64) as i32; + let probes: i32 = 0; + while (probes < seen.*.len) { + if (seen.*.data[index] == id) { return 0; } + if (seen.*.data[index] == 0) { seen.*.data[index] = id; return 1; } + index = index + 1; + if (index == seen.*.len) { index = 0; } + probes = probes + 1; + } + return 0 - 1; +} + +fn virtual_tree_project(source: ArrayList[VirtualTreeNode], revision: i64) VirtualTreeProjection { + if (source.len > 16384) { + return virtual_tree_projection_error(VirtualTreeError.too_many_nodes, 0 - 1, source.len); + } + if (revision < 0) { + return virtual_tree_projection_error(VirtualTreeError.invalid_node, 0 - 1, source.len); + } + let ancestor_ids: ArrayList[i64] = make[i64](32); + let ancestor_has_children: ArrayList[i32] = make[i32](32); + let seen: ArrayList[i64] = make[i64](source.len * 2 + 1); + let seen_index: i32 = 0; + while (seen_index < source.len * 2 + 1) { push[i64](&seen, 0); seen_index = seen_index + 1; } + let visible: ArrayList[VirtualTreeVisibleNode] = make[VirtualTreeVisibleNode](source.len); + let hidden_depth: i32 = 0 - 1; + let hash: i64 = virtual_tree_mix(17, revision); + let i: i32 = 0; + while (i < source.len) { + let node: VirtualTreeNode = source.data[i]; + if (virtual_tree_node_valid(node) == 0) { + free[i64](&ancestor_ids); free[i32](&ancestor_has_children); free[i64](&seen); + free[VirtualTreeVisibleNode](&visible); + return virtual_tree_projection_error(VirtualTreeError.invalid_node, i, source.len); + } + if (virtual_tree_seen_insert(&seen, node.id) != 1) { + free[i64](&ancestor_ids); free[i32](&ancestor_has_children); free[i64](&seen); + free[VirtualTreeVisibleNode](&visible); + return virtual_tree_projection_error(VirtualTreeError.duplicate_id, i, source.len); + } + if ((i == 0 && node.depth != 0) || + (i > 0 && node.depth > source.data[i - 1].depth + 1)) { + free[i64](&ancestor_ids); free[i32](&ancestor_has_children); free[i64](&seen); + free[VirtualTreeVisibleNode](&visible); + return virtual_tree_projection_error(VirtualTreeError.invalid_depth, i, source.len); + } + while (ancestor_ids.len > node.depth) { + ancestor_ids.len = ancestor_ids.len - 1; + ancestor_has_children.len = ancestor_has_children.len - 1; + } + if (node.depth == 0) { + if (node.parent_id != 0) { + free[i64](&ancestor_ids); free[i32](&ancestor_has_children); free[i64](&seen); + free[VirtualTreeVisibleNode](&visible); + return virtual_tree_projection_error(VirtualTreeError.invalid_parent, i, source.len); + } + } else if (ancestor_ids.len != node.depth || + ancestor_ids.data[node.depth - 1] != node.parent_id || + ancestor_has_children.data[node.depth - 1] == 0) { + free[i64](&ancestor_ids); free[i32](&ancestor_has_children); free[i64](&seen); + free[VirtualTreeVisibleNode](&visible); + return virtual_tree_projection_error(VirtualTreeError.invalid_parent, i, source.len); + } + push[i64](&ancestor_ids, node.id); + push[i32](&ancestor_has_children, node.has_children); + + if (hidden_depth >= 0 && node.depth <= hidden_depth) { hidden_depth = 0 - 1; } + if (hidden_depth < 0) { + push[VirtualTreeVisibleNode](&visible, VirtualTreeVisibleNode{ + .id = node.id, .parent_id = node.parent_id, .source_index = i, + .depth = node.depth, .has_children = node.has_children, + .expanded = node.expanded, .extent = node.extent, .revision = node.revision, + }); + if (node.has_children != 0 && node.expanded == 0) { hidden_depth = node.depth; } + } + hash = virtual_tree_mix(hash, node.id); hash = virtual_tree_mix(hash, node.parent_id); + hash = virtual_tree_mix(hash, node.depth as i64); + hash = virtual_tree_mix(hash, node.has_children as i64); + hash = virtual_tree_mix(hash, node.expanded as i64); + hash = virtual_tree_mix(hash, node.extent); hash = virtual_tree_mix(hash, node.revision); + i = i + 1; + } + free[i64](&ancestor_ids); free[i32](&ancestor_has_children); free[i64](&seen); + hash = virtual_tree_mix(hash, visible.len as i64); + i = 0; + while (i < visible.len) { hash = virtual_tree_mix(hash, visible.data[i].id); i = i + 1; } + return VirtualTreeProjection{ + .nodes = visible, .source_count = source.len, .revision = revision, + .projection_hash = hash, .error = VirtualTreeError.none, .error_index = 0 - 1, + }; +} + +fn virtual_tree_style_valid(style: VirtualTreeStyle) i32 { + return (style.row_extent > 0 && style.row_extent <= unbounded_extent() / 4 && + style.row_gap >= 0 && style.row_gap <= unbounded_extent() / 4 && + style.indent >= 0 && style.indent <= unbounded_extent() / 4 && + virtual_insets_valid(style.padding) == 1 && + style.overscan_before >= 0 && style.overscan_before <= 4096 && + style.overscan_after >= 0 && style.overscan_after <= 4096 && + style.max_live_nodes > 0 && style.max_live_nodes <= 4096) as i32; +} + +fn virtual_tree_layout(projection: VirtualTreeProjection, viewport: Size, + scroll_offset: i64, style: VirtualTreeStyle) VirtualTreeResult { + if (projection.error != VirtualTreeError.none) { + return virtual_tree_result_error(projection.error, projection.error_index); + } + if (virtual_tree_style_valid(style) == 0) { + return virtual_tree_result_error(VirtualTreeError.invalid_style, 0 - 1); + } + let overrides: ArrayList[VirtualExtentOverride] = make[VirtualExtentOverride](projection.nodes.len); + let i: i32 = 0; + while (i < projection.nodes.len) { + if (projection.nodes.data[i].extent != style.row_extent || + projection.nodes.data[i].revision != projection.revision) { + push[VirtualExtentOverride](&overrides, virtual_override( + i as i64, projection.nodes.data[i].extent, projection.nodes.data[i].revision + )); + } + i = i + 1; + } + let model: VirtualListModel = virtual_model( + projection.nodes.len as i64, 1, style.row_extent, style.row_gap, + projection.revision, overrides + ); + let list_style: VirtualListStyle = virtual_style(VirtualAxis.vertical, style.padding); + list_style.direction = style.direction; + list_style.overscan_before = style.overscan_before; + list_style.overscan_after = style.overscan_after; + list_style.max_live_items = style.max_live_nodes; + let layout: VirtualListResult = virtual_list_layout(model, viewport, scroll_offset, list_style); + free[VirtualExtentOverride](&overrides); + if (layout.error != VirtualListError.none) { + let index: i32 = layout.error_index as i32; + virtual_free(&layout); + return virtual_tree_result_error(VirtualTreeError.layout_error, index); + } + let placements: ArrayList[VirtualTreePlacement] = make[VirtualTreePlacement](layout.placements.len); + i = 0; + while (i < layout.placements.len) { + let row: VirtualPlacement = layout.placements.data[i]; + let node: VirtualTreeVisibleNode = projection.nodes.data[row.index as i32]; + let indentation: i64 = style.indent * (node.depth as i64); + if (indentation < 0 || indentation > row.frame.width) { indentation = row.frame.width; } + let content_frame: Rect = row.frame; + content_frame.width = row.frame.width - indentation; + if (style.direction == LayoutDirection.ltr) { content_frame.x = row.frame.x + indentation; } + push[VirtualTreePlacement](&placements, VirtualTreePlacement{ + .id = node.id, .parent_id = node.parent_id, + .source_index = node.source_index, .visible_index = row.index, + .depth = node.depth, .has_children = node.has_children, + .expanded = node.expanded, .frame = row.frame, + .content_frame = content_frame, .revision = node.revision, + }); + i = i + 1; + } + let result: VirtualTreeResult = VirtualTreeResult{ + .placements = placements, .viewport = layout.viewport, .content = layout.content, + .scroll_offset = layout.scroll_offset, .max_scroll = layout.max_scroll, + .first_visible = layout.first_visible, .last_visible = layout.last_visible, + .first_live = layout.first_live, .last_live = layout.last_live, + .semantic_count = projection.nodes.len as i64, .source_count = projection.source_count, + .layout_hash = virtual_tree_mix(projection.projection_hash, layout.layout_hash), + .error = VirtualTreeError.none, .error_index = 0 - 1, + }; + i = 0; + while (i < result.placements.len) { + result.layout_hash = virtual_tree_mix(result.layout_hash, result.placements.data[i].id); + result.layout_hash = virtual_tree_mix(result.layout_hash, result.placements.data[i].content_frame.x); + result.layout_hash = virtual_tree_mix(result.layout_hash, result.placements.data[i].content_frame.width); + i = i + 1; + } + virtual_free(&layout); + return result; +} diff --git a/src/replay/replay.zag b/src/replay/replay.zag index 813445f..7b379ed 100644 --- a/src/replay/replay.zag +++ b/src/replay/replay.zag @@ -7,6 +7,9 @@ @import("../layout/overlay.zag") @import("../layout/scroll.zag") @import("../layout/virtual_list.zag") +@import("../layout/virtual_table.zag") +@import("../layout/virtual_tree.zag") +@import("../core/virtual_lifecycle.zag") @import("../semantics/semantics.zag") @import("../render/cpu_raster.zag") @import("../motion/motion.zag") @@ -334,6 +337,68 @@ fn replay_runtime_free(router: *InputRouter, tree: *HitTree, motion: *MotionSche motion_scheduler_free(motion); } +fn replay_virtual_collections_hash(magnitude: i64) i64 { + let overrides: ArrayList[VirtualExtentOverride] = make[VirtualExtentOverride](1); + let rows: VirtualListModel = virtual_model(100, 3000, unit_scale(), 0, magnitude, overrides); + let columns: ArrayList[VirtualTableColumn] = make[VirtualTableColumn](2); + let pinned: VirtualTableColumn = virtual_table_column( + 1, 3 * unit_scale(), 2 * unit_scale(), 4 * unit_scale() + ); + pinned.pinned = 1; + push[VirtualTableColumn](&columns, pinned); + push[VirtualTableColumn](&columns, virtual_table_column( + 2, 3 * unit_scale(), 2 * unit_scale(), 4 * unit_scale() + )); + let table: VirtualTableResult = virtual_table_layout( + virtual_table_model(77, rows, columns, magnitude), + size(8 * unit_scale(), 6 * unit_scale()), magnitude * unit_scale(), 0, + virtual_table_style(unit_scale(), unit_scale(), insets_all(0)) + ); + free[VirtualTableColumn](&columns); free[VirtualExtentOverride](&overrides); + if (table.error != VirtualTableError.none) { + virtual_table_free(&table); + return 0; + } + + let source: ArrayList[VirtualTreeNode] = make[VirtualTreeNode](2); + push[VirtualTreeNode](&source, virtual_tree_node( + 4000, 0, 0, 1, (magnitude != 0) as i32, unit_scale(), magnitude + )); + push[VirtualTreeNode](&source, virtual_tree_node( + 4001, 4000, 1, 0, 0, unit_scale(), magnitude + )); + let projection: VirtualTreeProjection = virtual_tree_project(source, magnitude); + free[VirtualTreeNode](&source); + if (projection.error != VirtualTreeError.none) { + virtual_tree_projection_free(&projection); virtual_table_free(&table); + return 0; + } + let tree: VirtualTreeResult = virtual_tree_layout( + projection, size(8 * unit_scale(), 6 * unit_scale()), 0, + virtual_tree_style(unit_scale(), 0, unit_scale(), insets_all(0)) + ); + if (tree.error != VirtualTreeError.none) { + virtual_tree_free(&tree); virtual_tree_projection_free(&projection); + virtual_table_free(&table); return 0; + } + let live_ids: ArrayList[i64] = make[i64](tree.placements.len); + let i: i32 = 0; + while (i < tree.placements.len) { + push[i64](&live_ids, tree.placements.data[i].id); + i = i + 1; + } + let store: VirtualRetainedStore = virtual_store_make(8); + let lifecycle: VirtualLifecycleResult = virtual_store_reconcile(&store, live_ids); + free[i64](&live_ids); + let hash: i64 = 0; + if (lifecycle.error == VirtualLifecycleError.none) { + hash = display_mix(display_mix(table.layout_hash, tree.layout_hash), lifecycle.lifecycle_hash); + } + virtual_store_free(&store); virtual_tree_free(&tree); + virtual_tree_projection_free(&projection); virtual_table_free(&table); + return hash; +} + fn replay_build_scene(state_value: i64, backend_generation: i64) ReplayResult { let result: ReplayResult = replay_result_error(ReplayError.none, 0 - 1, 0); @@ -492,12 +557,24 @@ fn replay_build_scene(state_value: i64, backend_generation: i64) ReplayResult { semantics_tree_free(&semantics); return replay_result_error(ReplayError.scene_error, 0 - 1, 0); } + let collections_hash: i64 = replay_virtual_collections_hash(magnitude); + if (collections_hash == 0) { + virtual_free(&virtual_result); + grid_free(&grid_layout_result); + overlay_free(&overlay_layout_result); + flex_wrap_free(&layout); + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } result.layout_hash = display_mix( display_mix( display_mix(layout.layout_hash, overlay_layout_result.layout_hash), grid_layout_result.layout_hash ), - display_mix(virtual_result.layout_hash, scroll_state_hash(replay_scroll)) + display_mix( + display_mix(virtual_result.layout_hash, scroll_state_hash(replay_scroll)), + collections_hash + ) ); result.layout_lines = layout.lines.len; virtual_free(&virtual_result); diff --git a/tests/virtual_collections_contract.zag b/tests/virtual_collections_contract.zag new file mode 100644 index 0000000..f578d16 --- /dev/null +++ b/tests/virtual_collections_contract.zag @@ -0,0 +1,325 @@ +@import("std:list") +@import("../src/core/virtual_lifecycle.zag") +@import("../src/layout/virtual_table.zag") +@import("../src/layout/virtual_tree.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn table_contract(state: *TestState) void { + let row_overrides: ArrayList[VirtualExtentOverride] = make[VirtualExtentOverride](1); + let rows: VirtualListModel = virtual_model(1000000, 1000, 20, 1, 3, row_overrides); + let columns: ArrayList[VirtualTableColumn] = make[VirtualTableColumn](6); + let pinned: VirtualTableColumn = virtual_table_column(10, 40, 30, 60); + pinned.pinned = 1; pinned.resizable = 0; + push[VirtualTableColumn](&columns, pinned); + let i: i64 = 1; + while (i < 6) { push[VirtualTableColumn](&columns, virtual_table_column(10 + i, 50, 30, 80)); i = i + 1; } + let model: VirtualTableModel = virtual_table_model(7, rows, columns, 2); + let style: VirtualTableStyle = virtual_table_style(20, 2, insets_all(2)); + let vertical: i64 = virtual_item_offset(rows, + virtual_style(VirtualAxis.vertical, insets_all(0)), 500000); + let result: VirtualTableResult = virtual_table_layout( + model, size(180, 120), vertical, 60, style + ); + expect(state, result.error == VirtualTableError.none && result.semantic_rows == 1000000 && + result.semantic_columns == 6 && result.first_visible_row == 500000 && + result.last_visible_row == 500004, + "million-row Table projects the exact semantic row range without materializing the collection"); + expect(state, result.first_live_row == 499998 && result.last_live_row == 500006 && + result.first_visible_column == 2 && result.last_visible_column == 4 && + result.pinned_columns == 1 && result.cells.len == 60, + "Table bounds two-axis live cells while retaining pinned and overscanned columns"); + expect(state, result.cells.data[0].header == 1 && result.cells.data[0].pinned == 1 && + result.cells.data[0].frame.x == 2 && result.cells.data[1 + 2].row_index == 500000 && + result.cells.data[1 + 2].frame.y == 22, + "pinned headers and rows remain fixed while the body scrolls to a stable row ID"); + expect(state, result.cells.data[20].column_index == 2 && result.cells.data[20].frame.x == 36 && + result.max_horizontal_scroll == 124 && result.layout_hash != 0, + "scrolling columns resolve exact logical offsets and maximum horizontal range"); + let repeated: VirtualTableResult = virtual_table_layout(model, size(180, 120), vertical, 60, style); + expect(state, repeated.layout_hash == result.layout_hash, + "identical Table inputs produce identical cell identity and geometry"); + let rtl_style: VirtualTableStyle = style; rtl_style.direction = LayoutDirection.rtl; + let rtl: VirtualTableResult = virtual_table_layout(model, size(180, 120), vertical, 60, rtl_style); + expect(state, rtl.cells.data[0].frame.x == 138 && rtl.layout_hash != result.layout_hash, + "RTL mirrors pinned and scrolling Table columns without changing semantic order"); + virtual_table_free(&rtl); virtual_table_free(&repeated); virtual_table_free(&result); + + let resize: VirtualTableResizeResult = virtual_table_resize(&model, 12, 100); + expect(state, resize.error == VirtualTableError.none && resize.old_width == 50 && + resize.width == 80 && resize.consumed == 30 && resize.unconsumed == 70 && + resize.changed == 1 && model.revision == 3, + "Table resize exposes clamped consumption and advances exact model and column revisions"); + let fixed_resize: VirtualTableResizeResult = virtual_table_resize(&model, 10, 10); + expect(state, fixed_resize.error == VirtualTableError.column_not_resizable && + fixed_resize.consumed == 0 && fixed_resize.unconsumed == 10, + "nonresizable pinned columns reject mutation without losing requested delta truth"); + style.max_live_cells = 20; + let limited: VirtualTableResult = virtual_table_layout(model, size(180, 120), vertical, 60, style); + expect(state, limited.error == VirtualTableError.cell_limit_exceeded, + "Table cell residency ceilings fail before cell allocation"); + virtual_table_free(&limited); + free[VirtualTableColumn](&columns); free[VirtualExtentOverride](&row_overrides); +} + +fn table_failure_contract(state: *TestState) void { + let overrides: ArrayList[VirtualExtentOverride] = make[VirtualExtentOverride](1); + let rows: VirtualListModel = virtual_model(10, 1, 10, 0, 0, overrides); + let columns: ArrayList[VirtualTableColumn] = make[VirtualTableColumn](3); + push[VirtualTableColumn](&columns, virtual_table_column(1, 20, 10, 30)); + let late_pinned: VirtualTableColumn = virtual_table_column(2, 20, 10, 30); late_pinned.pinned = 1; + push[VirtualTableColumn](&columns, late_pinned); + let malformed: VirtualTableResult = virtual_table_layout( + virtual_table_model(1, rows, columns, 0), size(100, 80), 0, 0, + virtual_table_style(10, 1, insets_all(0)) + ); + expect(state, malformed.error == VirtualTableError.invalid_pinned_order && malformed.error_index == 1, + "pinned Table columns must form one deterministic logical-leading region"); + virtual_table_free(&malformed); + columns.data[1].pinned = 0; columns.data[1].id = 1; + let duplicate: VirtualTableResult = virtual_table_layout( + virtual_table_model(1, rows, columns, 0), size(100, 80), 0, 0, + virtual_table_style(10, 1, insets_all(0)) + ); + expect(state, duplicate.error == VirtualTableError.duplicate_column && duplicate.error_index == 1, + "duplicate Table column IDs fail at the exact column before layout"); + virtual_table_free(&duplicate); + free[VirtualTableColumn](&columns); free[VirtualExtentOverride](&overrides); +} + +fn tree_source() ArrayList[VirtualTreeNode] { + let source: ArrayList[VirtualTreeNode] = make[VirtualTreeNode](8); + push[VirtualTreeNode](&source, virtual_tree_node(1, 0, 0, 1, 1, 12, 1)); + push[VirtualTreeNode](&source, virtual_tree_node(2, 1, 1, 0, 0, 10, 1)); + push[VirtualTreeNode](&source, virtual_tree_node(3, 1, 1, 1, 0, 10, 1)); + push[VirtualTreeNode](&source, virtual_tree_node(4, 3, 2, 0, 0, 8, 1)); + push[VirtualTreeNode](&source, virtual_tree_node(5, 3, 2, 0, 0, 10, 1)); + push[VirtualTreeNode](&source, virtual_tree_node(6, 1, 1, 0, 0, 10, 1)); + push[VirtualTreeNode](&source, virtual_tree_node(7, 0, 0, 1, 0, 10, 1)); + push[VirtualTreeNode](&source, virtual_tree_node(8, 7, 1, 0, 0, 10, 1)); + return source; +} + +fn tree_contract(state: *TestState) void { + let source: ArrayList[VirtualTreeNode] = tree_source(); + let collapsed: VirtualTreeProjection = virtual_tree_project(source, 1); + expect(state, collapsed.error == VirtualTreeError.none && collapsed.source_count == 8 && + collapsed.nodes.len == 5 && collapsed.nodes.data[0].id == 1 && + collapsed.nodes.data[2].id == 3 && collapsed.nodes.data[3].id == 6 && + collapsed.nodes.data[4].id == 7, + "Tree projection removes collapsed descendants while preserving source and sibling identity"); + let style: VirtualTreeStyle = virtual_tree_style(10, 1, 4, insets_all(1)); + let ltr: VirtualTreeResult = virtual_tree_layout(collapsed, size(100, 30), 0, style); + expect(state, ltr.error == VirtualTreeError.none && ltr.semantic_count == 5 && + ltr.first_visible == 0 && ltr.last_visible == 2 && ltr.placements.len == 5, + "Tree virtualizes the expanded semantic projection with exact visible and live ranges"); + expect(state, ltr.placements.data[1].id == 2 && ltr.placements.data[1].depth == 1 && + ltr.placements.data[1].content_frame.x == 5 && + ltr.placements.data[1].content_frame.width == 94, + "Tree indentation is logical and preserves the full selectable row frame"); + let rtl_style: VirtualTreeStyle = style; rtl_style.direction = LayoutDirection.rtl; + let rtl: VirtualTreeResult = virtual_tree_layout(collapsed, size(100, 30), 0, rtl_style); + expect(state, rtl.placements.data[1].content_frame.x == 1 && + rtl.placements.data[1].content_frame.width == 94 && rtl.layout_hash != ltr.layout_hash, + "RTL moves Tree indentation to logical start without reordering nodes"); + + source.data[2].expanded = 1; source.data[2].revision = 2; + source.data[6].expanded = 1; source.data[6].revision = 2; + let expanded: VirtualTreeProjection = virtual_tree_project(source, 2); + expect(state, expanded.error == VirtualTreeError.none && expanded.nodes.len == 8 && + expanded.projection_hash != collapsed.projection_hash, + "expansion revisions restore descendants and alter deterministic projection identity"); + let expanded_layout: VirtualTreeResult = virtual_tree_layout(expanded, size(100, 120), 0, style); + expect(state, expanded_layout.semantic_count == 8 && expanded_layout.placements.data[3].id == 4 && + expanded_layout.placements.data[3].parent_id == 3 && + expanded_layout.placements.data[3].source_index == 3, + "expanded Tree placement retains parent identity and original source index"); + virtual_tree_free(&expanded_layout); virtual_tree_projection_free(&expanded); + virtual_tree_free(&rtl); virtual_tree_free(<r); virtual_tree_projection_free(&collapsed); + free[VirtualTreeNode](&source); +} + +fn tree_failure_contract(state: *TestState) void { + let source: ArrayList[VirtualTreeNode] = tree_source(); + source.data[2].id = 2; + let duplicate: VirtualTreeProjection = virtual_tree_project(source, 1); + expect(state, duplicate.error == VirtualTreeError.duplicate_id && duplicate.error_index == 2, + "duplicate Tree IDs fail before visible projection"); + virtual_tree_projection_free(&duplicate); + source.data[2].id = 3; source.data[3].depth = 3; + let depth: VirtualTreeProjection = virtual_tree_project(source, 1); + expect(state, depth.error == VirtualTreeError.invalid_depth && depth.error_index == 3, + "Tree depth jumps fail at the exact malformed preorder node"); + virtual_tree_projection_free(&depth); + source.data[3].depth = 2; source.data[3].parent_id = 999; + let parent: VirtualTreeProjection = virtual_tree_project(source, 1); + expect(state, parent.error == VirtualTreeError.invalid_parent && parent.error_index == 3, + "Tree parent mismatches fail instead of inferring ownership"); + virtual_tree_projection_free(&parent); + source.data[3].parent_id = 3; source.data[2].has_children = 0; source.data[2].expanded = 0; + let leaf_parent: VirtualTreeProjection = virtual_tree_project(source, 1); + expect(state, leaf_parent.error == VirtualTreeError.invalid_parent && leaf_parent.error_index == 3, + "Tree leaves cannot silently own child nodes"); + virtual_tree_projection_free(&leaf_parent); free[VirtualTreeNode](&source); +} + +fn id_range(start: i64, count: i32) ArrayList[i64] { + let ids: ArrayList[i64] = make[i64](count); + let i: i32 = 0; + while (i < count) { push[i64](&ids, start + i as i64); i = i + 1; } + return ids; +} + +fn lifecycle_contract(state: *TestState) void { + let store: VirtualRetainedStore = virtual_store_make(12); + let first_ids: ArrayList[i64] = id_range(100, 10); + let first: VirtualLifecycleResult = virtual_store_reconcile(&store, first_ids); + expect(state, first.error == VirtualLifecycleError.none && first.inserted == 10 && + first.live == 10 && first.resident == 10, + "initial virtual residency allocates one retained instance per live stable ID"); + let focus_index: i32 = virtual_store_find(store, 105); + let focus_instance: i64 = store.entries.data[focus_index].instance_id; + let focus_generation: i64 = store.entries.data[focus_index].generation; + expect(state, virtual_store_focus(&store, 105) == VirtualLifecycleError.none, + "virtual focus attaches to a retained stable ID"); + + let second_ids: ArrayList[i64] = id_range(200, 10); + let second: VirtualLifecycleResult = virtual_store_reconcile(&store, second_ids); + expect(state, second.error == VirtualLifecycleError.none && second.live == 10 && + second.resident <= 12 && second.evicted == 10 && + virtual_store_find(store, 105) >= 0 && store.entries.data[virtual_store_find(store, 105)].live == 0, + "offscreen focus remains retained while nonfocused instances recycle within capacity"); + + let return_ids: ArrayList[i64] = id_range(300, 9); + return_ids.data[0] = 105; + let returned: VirtualLifecycleResult = virtual_store_reconcile(&store, return_ids); + let restored_index: i32 = virtual_store_find(store, 105); + expect(state, returned.error == VirtualLifecycleError.none && returned.focus_restored == 1 && + store.entries.data[restored_index].instance_id == focus_instance && + store.entries.data[restored_index].generation == focus_generation && + store.entries.data[restored_index].live == 1, + "returning focused IDs restore the exact retained instance without generation drift"); + let before_hash: i64 = returned.lifecycle_hash; + let identical: VirtualLifecycleResult = virtual_store_reconcile(&store, return_ids); + expect(state, identical.rebound == 0 && identical.inserted == 0 && + identical.lifecycle_hash == before_hash, + "identical live residency performs no lifecycle revision or identity churn"); + + let duplicate_ids: ArrayList[i64] = id_range(400, 2); duplicate_ids.data[1] = 400; + let duplicate: VirtualLifecycleResult = virtual_store_reconcile(&store, duplicate_ids); + expect(state, duplicate.error == VirtualLifecycleError.duplicate_id && duplicate.error_index == 1, + "duplicate live IDs fail before retained-store mutation"); + free[i64](&duplicate_ids); free[i64](&return_ids); free[i64](&second_ids); free[i64](&first_ids); + virtual_store_free(&store); + + let tight: VirtualRetainedStore = virtual_store_make(4); + let focus_ids: ArrayList[i64] = id_range(1, 1); _ = virtual_store_reconcile(&tight, focus_ids); + _ = virtual_store_focus(&tight, 1); + let full_ids: ArrayList[i64] = id_range(10, 4); + let full: VirtualLifecycleResult = virtual_store_reconcile(&tight, full_ids); + expect(state, full.error == VirtualLifecycleError.too_many_live_nodes && + virtual_store_find(tight, 1) >= 0, + "offscreen focus consumes explicit capacity instead of being silently recycled"); + free[i64](&full_ids); free[i64](&focus_ids); virtual_store_free(&tight); +} + +fn property_contract(state: *TestState) void { + let valid: i32 = 1; + let overrides: ArrayList[VirtualExtentOverride] = make[VirtualExtentOverride](1); + let rows: VirtualListModel = virtual_model(10000, 100, 10, 1, 0, overrides); + let columns: ArrayList[VirtualTableColumn] = make[VirtualTableColumn](5); + let pinned: VirtualTableColumn = virtual_table_column(1, 20, 20, 20); pinned.pinned = 1; + push[VirtualTableColumn](&columns, pinned); + let i: i64 = 1; + while (i < 5) { push[VirtualTableColumn](&columns, virtual_table_column(1 + i, 30, 20, 50)); i = i + 1; } + let table: VirtualTableModel = virtual_table_model(9, rows, columns, 0); + let table_style: VirtualTableStyle = virtual_table_style(10, 1, insets_all(1)); + let scroll: i64 = 0; + while (scroll <= 80) { + let result: VirtualTableResult = virtual_table_layout(table, size(90, 70), 500, scroll, table_style); + if (result.error != VirtualTableError.none || result.cells.len > 64 || + result.cells.data[0].frame.x != 1 || result.semantic_rows != 10000 || + result.first_live_row > result.first_visible_row || + result.last_live_row < result.last_visible_row) { valid = 0; } + virtual_table_free(&result); + scroll = scroll + 7; + } + free[VirtualTableColumn](&columns); free[VirtualExtentOverride](&overrides); + + let tree: ArrayList[VirtualTreeNode] = tree_source(); + let mask: i32 = 0; + while (mask < 4) { + tree.data[2].expanded = mask & 1; + tree.data[6].expanded = (mask >> 1) & 1; + let projection: VirtualTreeProjection = virtual_tree_project(tree, mask as i64); + let expected: i32 = 5 + ((mask & 1) * 2) + ((mask >> 1) & 1); + if (projection.error != VirtualTreeError.none || projection.nodes.len != expected) { valid = 0; } + let j: i32 = 0; + while (j < projection.nodes.len) { + let k: i32 = j + 1; + while (k < projection.nodes.len) { + if (projection.nodes.data[j].id == projection.nodes.data[k].id) { valid = 0; } + k = k + 1; + } + j = j + 1; + } + virtual_tree_projection_free(&projection); + mask = mask + 1; + } + free[VirtualTreeNode](&tree); + + let wide_tree: ArrayList[VirtualTreeNode] = make[VirtualTreeNode](4096); + let wide_index: i32 = 0; + while (wide_index < 4096) { + push[VirtualTreeNode](&wide_tree, virtual_tree_node( + 10000 + wide_index as i64, 0, 0, 0, 0, 8, 0 + )); + wide_index = wide_index + 1; + } + let wide_projection: VirtualTreeProjection = virtual_tree_project(wide_tree, 0); + if (wide_projection.error != VirtualTreeError.none || wide_projection.nodes.len != 4096) { + valid = 0; + } + virtual_tree_projection_free(&wide_projection); free[VirtualTreeNode](&wide_tree); + + let store: VirtualRetainedStore = virtual_store_make(16); + let window: i32 = 0; + while (window < 100) { + let ids: ArrayList[i64] = id_range(1000 + window as i64 * 10, 10); + let lifecycle: VirtualLifecycleResult = virtual_store_reconcile(&store, ids); + if (lifecycle.error != VirtualLifecycleError.none || lifecycle.live != 10 || + lifecycle.resident > 16 || store.entries.len > 16) { valid = 0; } + free[i64](&ids); + window = window + 1; + } + virtual_store_free(&store); + expect(state, valid == 1, + "Table scroll 4096-node Tree expansion and 100-window recycling sweeps preserve bounded stable identity"); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + table_contract(&state); + table_failure_contract(&state); + tree_contract(&state); + tree_failure_contract(&state); + lifecycle_contract(&state); + property_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Virtual collections contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 54c9cd6..53813b5 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -26,6 +26,8 @@ cd "$root" "$tmp/grid-contract" "$znc" tests/scroll_virtual_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/scroll-virtual-contract" "$tmp/scroll-virtual-contract" +"$znc" tests/virtual_collections_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/virtual-collections-contract" +"$tmp/virtual-collections-contract" "$znc" tests/state_reconcile_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/state-reconcile-contract" "$tmp/state-reconcile-contract" "$znc" tests/semantics_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/semantics-contract" @@ -45,4 +47,4 @@ cd "$root" "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, semantics, Talkback, display lists, CPU raster, input, replay, and motion)\n' +printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, semantics, Talkback, display lists, CPU raster, input, replay, and motion)\n' From 9562335f7063480ea73209ebc3c36d07175dc1e1 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 08:48:23 -0700 Subject: [PATCH 021/103] Add virtual collection semantics and Talkback evidence --- CHANGELOG.md | 11 +- README.md | 31 +- contracts/talkback-protocol.json | 19 +- docs/architecture/README.md | 13 +- .../architecture/scroll-and-virtualization.md | 23 +- docs/automation/talkback.md | 19 +- src/automation/talkback.zag | 53 ++- src/replay/replay.zag | 22 ++ src/semantics/collections.zag | 351 ++++++++++++++++++ src/semantics/semantics.zag | 233 ++++++++++++ tests/collection_semantics_contract.zag | 151 ++++++++ tests/semantics_contract.zag | 73 ++++ tests/talkback_contract.zag | 13 +- tools/test-headless.sh | 4 +- 14 files changed, 982 insertions(+), 34 deletions(-) create mode 100644 src/semantics/collections.zag create mode 100644 tests/collection_semantics_contract.zag diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ca4f7f..9ec6aae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,16 @@ include a generated capability record and exact Zag compiler revision. column wrapping, RTL line bands, baseline-safe extents, per-line growth, typed overflow, malformed-input rejection, and layout identity. - An experimental owned semantics tree with roles, actions, focus order, - live regions, ranges, selection, text navigation, and stable generated IDs. + live regions, ranges, selection, text navigation, descriptions, bounds, + relationships, collection coordinates and counts, hierarchy, expansion, and + stable generated IDs. +- Bounded virtual Table and Tree semantic projection with full logical counts, + exact live geometry, deterministic collection IDs, and collision-atomic + preflight. - The first in-process Zagkit Talkback dispatcher and machine-readable protocol - contract with ID-first targeting and explicit scale-aware pixel fallback. + with protocol 0.2 query evidence for semantic geometry, collection metadata, + tree state, owned-text lengths, state flags, deterministic hashes, ID-first + targeting, and explicit scale-aware pixel fallback. - An experimental immutable display list for explicit paints, resources, clips, transforms, layers, and effects with deterministic content identity. - A versioned canonical display-list codec with bounded validated decoding and diff --git a/README.md b/README.md index f964d7d..20ddae5 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ is normative in [DEPENDENCIES.md](DEPENDENCIES.md). | Product and architecture contract | accepted | [RFC index](docs/rfcs/README.md) | | Compiler dependency | pinned, prerequisites incomplete | [toolchain lock](contracts/toolchain.json) | | Platform shells | unavailable | [support matrix](SUPPORT.md) | -| Headless core | experimental state, reconciliation, intrinsic measurement, geometry, Flex, Grid, Overlay, scroll, virtual list, semantics, Talkback, display lists, CPU raster, input, replay, and motion | [headless test](tools/test-headless.sh) | +| Headless core | experimental state, reconciliation, intrinsic measurement, geometry, Flex, Grid, Overlay, scroll, virtual collections, collection semantics, Talkback, display lists, CPU raster, input, replay, and motion | [headless test](tools/test-headless.sh) | | Components and visual language | inventory only, visual review pending | [component inventory](contracts/components.json) | | Flex and Zagkit Talkback | Flex foundation and in-process ID-first Talkback dispatch executing; native transport remains unavailable | [Talkback contract](docs/automation/talkback.md) | | Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | @@ -107,17 +107,24 @@ generation when its logical ID returns. These identities participate in replay. Sorting, selection, editing, drag reordering, component semantics, and native interaction remain open. -The semantics slice owns copied names and values, stable keys, explicit action -capabilities, deterministic focus order, live-region state, ranges, selection, -and text-navigation bounds. Invalid parents, duplicate IDs and focus order, -malformed ranges, and malformed selections fail visibly before tree mutation. -Native accessibility adapters and the Zagkit Talkback protocol remain open. - -The first in-process Zagkit Talkback dispatcher now resolves queries and emits -validated actions against those semantic IDs, rejects stale revisions and -unavailable actions, keeps pixel fallback disabled unless explicitly -advertised, applies recorded display scale to pixel bounds, and logs accepted -and rejected requests in one ordered stream. +The semantics slice owns copied names, descriptions, and values; stable keys; +logical bounds; label, description, and control relationships; explicit action +capabilities; deterministic focus order; live-region state; ranges; selection; +text navigation; collection coordinates and counts; tree levels; set position; +and expansion truth. Invalid geometry, dangling relationships, collection +metadata, parents, IDs, focus order, ranges, and selections fail visibly before +tree mutation. Virtual Table and Tree projection retain full logical counts +while materializing only live semantic rows and cells. Composite collection +identities are deterministically mapped to `NodeKey` and collision-preflighted +before any tree mutation. Native accessibility adapters remain open. + +The in-process Zagkit Talkback dispatcher resolves queries and emits validated +actions against those semantic IDs. Query responses expose role, actions, +fixed-point bounds, collection coordinates and counts, tree state, owned-text +lengths, state flags, and a deterministic semantic evidence hash. Dispatch +rejects stale revisions and unavailable actions, keeps pixel fallback disabled +unless explicitly advertised, applies recorded display scale to pixel bounds, +and logs accepted and rejected requests in one ordered stream. It is not yet a native automation transport; the exact available and unavailable surface is documented in [the protocol contract](docs/automation/talkback.md). diff --git a/contracts/talkback-protocol.json b/contracts/talkback-protocol.json index f084d5b..c705715 100644 --- a/contracts/talkback-protocol.json +++ b/contracts/talkback-protocol.json @@ -1,6 +1,6 @@ { "name": "Zagkit Talkback", - "version": "0.1.0-experimental", + "version": "0.2.0-experimental", "android_assistive_technology": "Android TalkBack", "transport": "not-yet-available", "targeting": { @@ -64,7 +64,22 @@ "event-sequence", "resolved-node-index", "result-count", - "emitted-action" + "emitted-action", + "resolved-role", + "action-mask", + "has-bounds", + "bounds", + "row-count", + "column-count", + "row-index", + "column-index", + "level", + "expanded", + "name-length", + "description-length", + "value-length", + "state-flags", + "evidence-hash" ], "event_contract": { "ordered": true, diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 74b6870..ddb1c39 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -55,11 +55,14 @@ areas, text scale, breakpoints, and physical RTL without changing logical ID order. Wrapped output retains line ranges, exact placement identity, and fail-visible overflow or malformed input. See [flex.md](flex.md). -The parallel experimental `SemanticsTree` retains owned names and values plus -roles, actions, focus order, live regions, ranges, selection, and text -navigation. Its stable `NodeKey` identity is shared with rendering, so future -accessibility adapters, semantic tests, and Zagkit Talkback automation can -query product meaning without reconstructing it from pixels. +The parallel experimental `SemanticsTree` retains owned names, descriptions, +and values plus roles, actions, focus order, live regions, ranges, selection, +text navigation, fixed-point bounds, relationships, collection coordinates, +full collection counts, tree level, set position, and expansion truth. Virtual +Table and Tree output projects only live semantic descendants while preserving +the complete logical range on the root. Stable `NodeKey` identity is shared +with rendering, so accessibility adapters, semantic tests, and Zagkit Talkback +automation query product meaning without reconstructing it from pixels. Rendering begins with an experimental immutable `DisplayList`. Every operation retains its owning `NodeKey`; paths, images, glyph runs, clips, transforms, diff --git a/docs/architecture/scroll-and-virtualization.md b/docs/architecture/scroll-and-virtualization.md index d7f6acf..907c451 100644 --- a/docs/architecture/scroll-and-virtualization.md +++ b/docs/architecture/scroll-and-virtualization.md @@ -69,6 +69,14 @@ minimum and maximum bounds, reports consumed and unconsumed delta, and advances both column and model revisions only when width changes. Pinned columns must be one contiguous logical-leading region and mirror physically under RTL. +`semantics_project_table` creates a bounded parallel tree from those live +cells. The Table root retains the full logical row and column counts. Live row, +header, and cell nodes retain exact fixed-point frames and zero-based logical +indices. Their `NodeKey` values derive deterministically from the authoritative +composite identity; all generated keys and the caller-owned root ID are checked +against the existing semantic tree before the first node is added. A collision +therefore fails without a partial semantic projection. + ## Tree projection Tree input is canonical preorder with stable ID, explicit parent ID, depth, @@ -83,6 +91,14 @@ to the right without changing semantic order. An open-addressed stable-ID index keeps duplicate validation linear; the current retained projection remains capped at 16,384 source nodes as an explicit allocation bound. +`semantics_project_tree` retains the visible projection count on the Tree root +while adding only live placements. Each live item exposes its stable source ID, +full row frame, zero-based visible row, one-based hierarchy level and set +position, and an expand or collapse action that matches current state. Virtual +items are direct semantic children of the collection root so an offscreen +ancestor is never fabricated as a live node; level metadata preserves the +hierarchy required by native accessibility adapters. + ## Focus and recycling lifecycle `VirtualRetainedStore` separates logical IDs from recyclable retained instances. @@ -106,9 +122,10 @@ The executable suites prove a one-million-row mid-list query with nine live placements and fewer than 64 examined records, a million-row two-axis Table, collapsed and expanded Tree projections, sparse variable extents, anchor and offscreen-focus stability, RTL placement, exact empty and padding-only ranges, -bounded allocation failures, malformed input, overflow rejection, and scroll, -expansion, and 100-window recycling sweeps. Replay incorporates List, Table, -Tree, scroll, and lifecycle identities. Stopwatch timing in CI is not a 120 Hz +bounded allocation failures, malformed input, overflow rejection, scroll, +expansion, semantic collection projection, collision-atomic identity, and +100-window recycling sweeps. Replay incorporates List, Table, Tree, scroll, +semantics, and lifecycle identities. Stopwatch timing in CI is not a 120 Hz claim. Native input, semantic adapters, editable cells, sorting, selection, drag reordering, general Tree indexing, and reference-hardware performance remain open. diff --git a/docs/automation/talkback.md b/docs/automation/talkback.md index db6a024..d7f38d0 100644 --- a/docs/automation/talkback.md +++ b/docs/automation/talkback.md @@ -26,14 +26,19 @@ must be positive. Timeouts must be between 1 and 300000 milliseconds. Every dispatch receives one monotonic event sequence and records the command, target kind, target ID, semantic revision, status, coordinates, and scale. -The first in-process slice resolves semantic discovery and queries and validates +The in-process slice resolves semantic discovery and queries and validates click, type, focus, and scroll before emitting them into the ordered event -stream. Responses distinguish read-only results from emitted actions and name -the resolved semantic-node index. Capability reports and timeline counts are -available. Key, drag, gesture, wait, assertion payloads, screenshots, snapshots, -replay, native action consumption, and native transport remain unavailable and -fail closed. Advertising a command in the protocol vocabulary does not claim -its runtime capability. +stream. Protocol minor version 2 query responses distinguish read-only results +from emitted actions and expose the resolved node index, role, action mask, +fixed-point bounds, collection counts and coordinates, tree level and expansion +state, owned-text lengths, state flags, and a deterministic evidence hash over +the complete semantic node. This makes geometry and semantic-state changes +observable even before the native transport gains structured text payloads. +Capability reports and timeline counts are available. Key, drag, gesture, +wait, assertion payloads, screenshots, snapshots, replay, native action +consumption, and native transport remain unavailable and fail closed. +Advertising a command in the protocol vocabulary does not claim its runtime +capability. ## Planned agent contract diff --git a/src/automation/talkback.zag b/src/automation/talkback.zag index a9c3516..0151330 100644 --- a/src/automation/talkback.zag +++ b/src/automation/talkback.zag @@ -68,6 +68,21 @@ struct TalkbackResponse { resolved_node_index: i32, result_count: i32, emitted_action: i32, + resolved_role: SemanticRole, + action_mask: i64, + has_bounds: i32, + bounds: Rect, + row_count: i64, + column_count: i64, + row_index: i64, + column_index: i64, + level: i64, + expanded: i32, + name_length: i32, + description_length: i32, + value_length: i32, + state_flags: i32, + evidence_hash: i64, } struct TalkbackEvent { @@ -93,7 +108,7 @@ struct TalkbackSession { fn talkback_capabilities() TalkbackCapabilities { return TalkbackCapabilities{ .protocol_major = 0, - .protocol_minor = 1, + .protocol_minor = 2, .semantic_query = 1, .id_actions = 1, .pixel_fallback = 0, @@ -236,6 +251,21 @@ fn talkback_record(session: *TalkbackSession, tree_revision: i64, request: Talkb .resolved_node_index = 0 - 1, .result_count = 0, .emitted_action = 0, + .resolved_role = SemanticRole.group, + .action_mask = 0, + .has_bounds = 0, + .bounds = rect(0, 0, 0, 0), + .row_count = 0 - 1, + .column_count = 0 - 1, + .row_index = 0 - 1, + .column_index = 0 - 1, + .level = 0, + .expanded = 0 - 1, + .name_length = 0, + .description_length = 0, + .value_length = 0, + .state_flags = 0, + .evidence_hash = 0, }; } @@ -293,6 +323,27 @@ fn talkback_dispatch(session: *TalkbackSession, tree: SemanticsTree, viewport: R let response: TalkbackResponse = talkback_record(session, tree.revision, request, TalkbackStatus.accepted); response.resolved_node_index = node_index; response.result_count = 1; + response.resolved_role = tree.nodes.data[node_index].role; + response.action_mask = tree.nodes.data[node_index].action_mask; + response.has_bounds = tree.nodes.data[node_index].has_bounds; + response.bounds = tree.nodes.data[node_index].bounds; + response.row_count = tree.nodes.data[node_index].row_count; + response.column_count = tree.nodes.data[node_index].column_count; + response.row_index = tree.nodes.data[node_index].row_index; + response.column_index = tree.nodes.data[node_index].column_index; + response.level = tree.nodes.data[node_index].level; + response.expanded = tree.nodes.data[node_index].expanded; + response.name_length = tree.nodes.data[node_index].name.len; + response.description_length = tree.nodes.data[node_index].description.len; + response.value_length = tree.nodes.data[node_index].value.len; + response.state_flags = tree.nodes.data[node_index].disabled; + if (tree.nodes.data[node_index].selected != 0) { + response.state_flags = response.state_flags | 2; + } + if (tree.nodes.data[node_index].hidden != 0) { + response.state_flags = response.state_flags | 4; + } + response.evidence_hash = semantic_node_evidence_hash(tree.nodes.data[node_index]); if (required_action != 0) { response.emitted_action = 1; } return response; } diff --git a/src/replay/replay.zag b/src/replay/replay.zag index 7b379ed..e23dab7 100644 --- a/src/replay/replay.zag +++ b/src/replay/replay.zag @@ -291,6 +291,7 @@ fn replay_semantics_hash(tree: SemanticsTree) i64 { hash = display_mix(hash, node.parent.generation); hash = display_mix(hash, node.role as i64); hash = replay_hash_text(hash, node.name); + hash = replay_hash_text(hash, node.description); hash = replay_hash_text(hash, node.value); hash = display_mix(hash, node.action_mask); hash = display_mix(hash, node.focus_order); @@ -306,6 +307,27 @@ fn replay_semantics_hash(tree: SemanticsTree) i64 { hash = display_mix(hash, node.text_length); hash = display_mix(hash, node.selection_start); hash = display_mix(hash, node.selection_end); + hash = display_mix(hash, node.has_bounds as i64); + hash = display_mix(hash, node.bounds.x); + hash = display_mix(hash, node.bounds.y); + hash = display_mix(hash, node.bounds.width); + hash = display_mix(hash, node.bounds.height); + hash = display_mix(hash, node.labelled_by.value); + hash = display_mix(hash, node.labelled_by.generation); + hash = display_mix(hash, node.described_by.value); + hash = display_mix(hash, node.described_by.generation); + hash = display_mix(hash, node.controls.value); + hash = display_mix(hash, node.controls.generation); + hash = display_mix(hash, node.row_count); + hash = display_mix(hash, node.column_count); + hash = display_mix(hash, node.row_index); + hash = display_mix(hash, node.column_index); + hash = display_mix(hash, node.row_span); + hash = display_mix(hash, node.column_span); + hash = display_mix(hash, node.level); + hash = display_mix(hash, node.set_size); + hash = display_mix(hash, node.position_in_set); + hash = display_mix(hash, node.expanded as i64); i = i + 1; } return hash; diff --git a/src/semantics/collections.zag b/src/semantics/collections.zag new file mode 100644 index 0000000..4828309 --- /dev/null +++ b/src/semantics/collections.zag @@ -0,0 +1,351 @@ +@import("std:list") +@import("semantics.zag") +@import("../layout/virtual_table.zag") +@import("../layout/virtual_tree.zag") + +enum CollectionSemanticsError { + none, + invalid_result, + missing_parent, + identity_collision, + semantic_error, +} + +struct CollectionSemanticsResult { + root_id: NodeKey, + nodes_added: i32, + live_items: i32, + logical_items: i64, + error: CollectionSemanticsError, + semantic_error: SemanticsError, + error_index: i32, +} + +fn collection_semantics_result(root_id: NodeKey) CollectionSemanticsResult { + return CollectionSemanticsResult{ + .root_id = root_id, .nodes_added = 0, .live_items = 0, .logical_items = 0, + .error = CollectionSemanticsError.none, .semantic_error = SemanticsError.none, + .error_index = 0 - 1, + }; +} + +fn collection_semantics_mix(hash: i64, value: i64) i64 { + return (hash * 1099511628211) ^ value; +} + +fn collection_semantic_key(root_id: NodeKey, kind: i64, + item_id: i64, column_id: i64) NodeKey { + let hash: i64 = collection_semantics_mix(17, root_id.value); + hash = collection_semantics_mix(hash, root_id.generation); + hash = collection_semantics_mix(hash, kind); + hash = collection_semantics_mix(hash, item_id); + hash = collection_semantics_mix(hash, column_id); + return node_key_scoped(hash, root_id.value ^ root_id.generation); +} + +fn semantic_table_row_key(root_id: NodeKey, row_id: i64, header: i32) NodeKey { + let kind: i64 = 100; + if (header != 0) { kind = 101; } + return collection_semantic_key(root_id, kind, row_id, 0); +} + +fn semantic_table_cell_key(root_id: NodeKey, cell_id: VirtualTableCellId) NodeKey { + let kind: i64 = 200; + if (cell_id.header != 0) { kind = 201; } + return collection_semantic_key(root_id, kind, cell_id.row_id, cell_id.column_id); +} + +fn semantic_tree_item_key(root_id: NodeKey, item_id: i64) NodeKey { + return collection_semantic_key(root_id, 300, item_id, 0); +} + +fn collection_key_preflight(tree: SemanticsTree, keys: *ArrayList[NodeKey], + key: NodeKey) i32 { + if (node_key_equal(key, semantic_root_key()) == 1 || + node_key_equal(key, semantic_none_key()) == 1) { return 0; } + if (semantics_find_index(tree, key) >= 0) { return 0; } + let i: i32 = 0; + while (i < keys.*.len) { + if (node_key_equal(keys.*.data[i], key) == 1) { return 0; } + i = i + 1; + } + push[NodeKey](keys, key); + return 1; +} + +fn collection_parent_valid(tree: SemanticsTree, parent: NodeKey) i32 { + return (node_key_equal(parent, semantic_root_key()) == 1 || + semantics_find_index(tree, parent) >= 0) as i32; +} + +fn collection_rect_valid(bounds: Rect) i32 { + return (bounds.x >= 0 - unbounded_extent() && bounds.x <= unbounded_extent() && + bounds.y >= 0 - unbounded_extent() && bounds.y <= unbounded_extent() && + bounds.width >= 0 && bounds.width <= unbounded_extent() && + bounds.height >= 0 && bounds.height <= unbounded_extent() && + bounds.x <= unbounded_extent() - bounds.width && + bounds.y <= unbounded_extent() - bounds.height) as i32; +} + +fn collection_table_cell_valid(table: VirtualTableResult, + cell: VirtualTableCell) i32 { + if (cell.id.table_id <= 0 || cell.id.row_id != cell.row_id || + cell.id.column_id != cell.column_id || cell.id.header != cell.header || + cell.column_id <= 0 || cell.column_index < 0 || + cell.column_index >= table.semantic_columns || + (cell.header != 0 && cell.header != 1) || collection_rect_valid(cell.frame) == 0) { + return 0; + } + if (cell.header != 0) { + return (cell.row_id == 0 && cell.row_index == 0 - 1) as i32; + } + return (cell.row_id > 0 && cell.row_index >= 0 && + cell.row_index < table.semantic_rows) as i32; +} + +fn collection_tree_placement_valid(layout: VirtualTreeResult, + placement: VirtualTreePlacement) i32 { + return (placement.id > 0 && placement.parent_id >= 0 && + placement.visible_index >= 0 && placement.visible_index < layout.semantic_count && + placement.depth >= 0 && placement.depth <= 512 && + (placement.has_children == 0 || placement.has_children == 1) && + (placement.expanded == 0 || placement.expanded == 1) && + (placement.has_children != 0 || placement.expanded == 0) && + collection_rect_valid(placement.frame) == 1 && + collection_rect_valid(placement.content_frame) == 1) as i32; +} + +fn collection_semantics_rollback(tree: *SemanticsTree, + checkpoint: SemanticsCheckpoint, result: *CollectionSemanticsResult, + error: SemanticsError, index: i32) void { + _ = semantics_tree_rollback(tree, checkpoint); + result.*.nodes_added = 0; + result.*.error = CollectionSemanticsError.semantic_error; + result.*.semantic_error = error; + result.*.error_index = index; +} + +fn collection_table_row_bounds(table: VirtualTableResult, + row_id: i64, header: i32) Rect { + let found: i32 = 0; + let bounds: Rect = rect(0, 0, 0, 0); + let i: i32 = 0; + while (i < table.cells.len) { + let cell: VirtualTableCell = table.cells.data[i]; + if (cell.row_id == row_id && cell.header == header) { + if (found == 0) { + bounds = cell.frame; + found = 1; + } else { + let left: i64 = bounds.x; + if (cell.frame.x < left) { left = cell.frame.x; } + let top: i64 = bounds.y; + if (cell.frame.y < top) { top = cell.frame.y; } + let right: i64 = bounds.x + bounds.width; + if (cell.frame.x + cell.frame.width > right) { + right = cell.frame.x + cell.frame.width; + } + let bottom: i64 = bounds.y + bounds.height; + if (cell.frame.y + cell.frame.height > bottom) { + bottom = cell.frame.y + cell.frame.height; + } + bounds = rect(left, top, right - left, bottom - top); + } + } + i = i + 1; + } + return bounds; +} + +fn collection_table_row_first(table: VirtualTableResult, + cell_index: i32) i32 { + let cell: VirtualTableCell = table.cells.data[cell_index]; + let i: i32 = 0; + while (i < cell_index) { + if (table.cells.data[i].row_id == cell.row_id && + table.cells.data[i].header == cell.header) { return 0; } + i = i + 1; + } + return 1; +} + +fn semantics_project_table(tree: *SemanticsTree, table: VirtualTableResult, + root_id: NodeKey, parent: NodeKey, name: []u8) CollectionSemanticsResult { + let result: CollectionSemanticsResult = collection_semantics_result(root_id); + result.live_items = table.cells.len; + result.logical_items = table.semantic_rows * table.semantic_columns as i64; + if (table.error != VirtualTableError.none || table.semantic_rows < 0 || + table.semantic_columns <= 0 || table.viewport.width <= 0 || table.viewport.height <= 0) { + result.error = CollectionSemanticsError.invalid_result; + result.error_index = table.error_index; + return result; + } + if (collection_parent_valid(tree.*, parent) == 0) { + result.error = CollectionSemanticsError.missing_parent; + return result; + } + + let keys: ArrayList[NodeKey] = make[NodeKey](table.cells.len * 2 + 2); + if (collection_key_preflight(tree.*, &keys, root_id) == 0) { + free[NodeKey](&keys); result.error = CollectionSemanticsError.identity_collision; return result; + } + let i: i32 = 0; + while (i < table.cells.len) { + let cell: VirtualTableCell = table.cells.data[i]; + if (collection_table_cell_valid(table, cell) == 0) { + free[NodeKey](&keys); result.error = CollectionSemanticsError.invalid_result; + result.error_index = i; return result; + } + if (collection_table_row_first(table, i) != 0 && + collection_key_preflight(tree.*, &keys, + semantic_table_row_key(root_id, cell.row_id, cell.header)) == 0) { + free[NodeKey](&keys); result.error = CollectionSemanticsError.identity_collision; + result.error_index = i; return result; + } + if (collection_key_preflight(tree.*, &keys, + semantic_table_cell_key(root_id, cell.id)) == 0) { + free[NodeKey](&keys); result.error = CollectionSemanticsError.identity_collision; + result.error_index = i; return result; + } + i = i + 1; + } + free[NodeKey](&keys); + + let checkpoint: SemanticsCheckpoint = semantics_tree_checkpoint(tree.*); + let root: SemanticsSpec = semantics_spec(root_id, parent, SemanticRole.table, name); + root.row_count = table.semantic_rows; + root.column_count = table.semantic_columns as i64; + root.has_bounds = 1; + root.bounds = rect(0, 0, table.viewport.width, table.viewport.height); + root.action_mask = semantic_actions(SemanticAction.scroll); + let added: SemanticsError = semantics_add(tree, root); + if (added != SemanticsError.none) { + result.error = CollectionSemanticsError.semantic_error; + result.semantic_error = added; + return result; + } + result.nodes_added = 1; + + i = 0; + while (i < table.cells.len) { + let cell: VirtualTableCell = table.cells.data[i]; + let row_id: NodeKey = semantic_table_row_key(root_id, cell.row_id, cell.header); + if (collection_table_row_first(table, i) != 0) { + let row: SemanticsSpec = semantics_spec(row_id, root_id, SemanticRole.row, ""); + row.has_bounds = 1; + row.bounds = collection_table_row_bounds(table, cell.row_id, cell.header); + if (cell.header == 0) { + row.row_index = cell.row_index; + row.row_span = 1; + } + let row_error: SemanticsError = semantics_add(tree, row); + if (row_error != SemanticsError.none) { + collection_semantics_rollback(tree, checkpoint, &result, row_error, i); + return result; + } + result.nodes_added = result.nodes_added + 1; + } + let cell_spec: SemanticsSpec = semantics_spec( + semantic_table_cell_key(root_id, cell.id), row_id, SemanticRole.cell, "" + ); + cell_spec.has_bounds = 1; + cell_spec.bounds = cell.frame; + cell_spec.column_index = cell.column_index as i64; + cell_spec.column_span = 1; + if (cell.header == 0) { + cell_spec.row_index = cell.row_index; + cell_spec.row_span = 1; + } + let cell_error: SemanticsError = semantics_add(tree, cell_spec); + if (cell_error != SemanticsError.none) { + collection_semantics_rollback(tree, checkpoint, &result, cell_error, i); + return result; + } + result.nodes_added = result.nodes_added + 1; + i = i + 1; + } + return result; +} + +fn semantics_project_tree(tree: *SemanticsTree, layout: VirtualTreeResult, + root_id: NodeKey, parent: NodeKey, name: []u8) CollectionSemanticsResult { + let result: CollectionSemanticsResult = collection_semantics_result(root_id); + result.live_items = layout.placements.len; + result.logical_items = layout.semantic_count; + if (layout.error != VirtualTreeError.none || layout.semantic_count < 0 || + layout.viewport.width <= 0 || layout.viewport.height <= 0) { + result.error = CollectionSemanticsError.invalid_result; + result.error_index = layout.error_index; + return result; + } + if (collection_parent_valid(tree.*, parent) == 0) { + result.error = CollectionSemanticsError.missing_parent; + return result; + } + let keys: ArrayList[NodeKey] = make[NodeKey](layout.placements.len + 1); + if (collection_key_preflight(tree.*, &keys, root_id) == 0) { + free[NodeKey](&keys); result.error = CollectionSemanticsError.identity_collision; return result; + } + let i: i32 = 0; + while (i < layout.placements.len) { + if (collection_tree_placement_valid(layout, layout.placements.data[i]) == 0) { + free[NodeKey](&keys); result.error = CollectionSemanticsError.invalid_result; + result.error_index = i; return result; + } + if (collection_key_preflight(tree.*, &keys, + semantic_tree_item_key(root_id, layout.placements.data[i].id)) == 0) { + free[NodeKey](&keys); result.error = CollectionSemanticsError.identity_collision; + result.error_index = i; return result; + } + i = i + 1; + } + free[NodeKey](&keys); + + let checkpoint: SemanticsCheckpoint = semantics_tree_checkpoint(tree.*); + let root: SemanticsSpec = semantics_spec(root_id, parent, SemanticRole.tree, name); + root.row_count = layout.semantic_count; + root.column_count = 1; + root.has_bounds = 1; + root.bounds = rect(0, 0, layout.viewport.width, layout.viewport.height); + root.action_mask = semantic_actions(SemanticAction.scroll); + let added: SemanticsError = semantics_add(tree, root); + if (added != SemanticsError.none) { + result.error = CollectionSemanticsError.semantic_error; + result.semantic_error = added; + return result; + } + result.nodes_added = 1; + i = 0; + while (i < layout.placements.len) { + let placement: VirtualTreePlacement = layout.placements.data[i]; + let item: SemanticsSpec = semantics_spec( + semantic_tree_item_key(root_id, placement.id), root_id, + SemanticRole.tree_item, "" + ); + item.has_bounds = 1; + item.bounds = placement.frame; + item.row_index = placement.visible_index; + item.row_span = 1; + item.column_index = 0; + item.column_span = 1; + item.level = placement.depth as i64 + 1; + item.set_size = layout.semantic_count; + item.position_in_set = placement.visible_index + 1; + if (placement.has_children != 0) { + item.expanded = placement.expanded; + if (placement.expanded != 0) { + item.action_mask = semantic_actions(SemanticAction.collapse); + } else { + item.action_mask = semantic_actions(SemanticAction.expand); + } + } + let item_error: SemanticsError = semantics_add(tree, item); + if (item_error != SemanticsError.none) { + collection_semantics_rollback(tree, checkpoint, &result, item_error, i); + return result; + } + result.nodes_added = result.nodes_added + 1; + i = i + 1; + } + return result; +} diff --git a/src/semantics/semantics.zag b/src/semantics/semantics.zag index bdbd1be..bff6c57 100644 --- a/src/semantics/semantics.zag +++ b/src/semantics/semantics.zag @@ -1,5 +1,6 @@ @import("std:list") @import("../core/view_contract.zag") +@import("../core/geometry.zag") enum SemanticRole { application, @@ -46,11 +47,16 @@ enum LiveRegion { off, polite, assertive } enum SemanticsError { none, + invalid_id, duplicate_id, missing_parent, duplicate_focus_order, invalid_range, invalid_text_selection, + invalid_bounds, + invalid_relationship, + invalid_collection, + invalid_state, } struct SemanticsSpec { @@ -58,6 +64,7 @@ struct SemanticsSpec { parent: NodeKey, role: SemanticRole, name: []u8, + description: []u8, value: []u8, action_mask: i64, focus_order: i64, @@ -73,6 +80,21 @@ struct SemanticsSpec { text_length: i64, selection_start: i64, selection_end: i64, + has_bounds: i32, + bounds: Rect, + labelled_by: NodeKey, + described_by: NodeKey, + controls: NodeKey, + row_count: i64, + column_count: i64, + row_index: i64, + column_index: i64, + row_span: i64, + column_span: i64, + level: i64, + set_size: i64, + position_in_set: i64, + expanded: i32, } struct SemanticsNode { @@ -80,6 +102,7 @@ struct SemanticsNode { parent: NodeKey, role: SemanticRole, name: ArrayList[u8], + description: ArrayList[u8], value: ArrayList[u8], action_mask: i64, focus_order: i64, @@ -95,6 +118,21 @@ struct SemanticsNode { text_length: i64, selection_start: i64, selection_end: i64, + has_bounds: i32, + bounds: Rect, + labelled_by: NodeKey, + described_by: NodeKey, + controls: NodeKey, + row_count: i64, + column_count: i64, + row_index: i64, + column_index: i64, + row_span: i64, + column_span: i64, + level: i64, + set_size: i64, + position_in_set: i64, + expanded: i32, } struct SemanticsTree { @@ -104,7 +142,15 @@ struct SemanticsTree { error_node: NodeKey, } +struct SemanticsCheckpoint { + length: i32, + revision: i64, + last_error: SemanticsError, + error_node: NodeKey, +} + fn semantic_root_key() NodeKey { return node_key(0 - 1); } +fn semantic_none_key() NodeKey { return node_key(0 - 2); } fn semantic_action_bit(action: SemanticAction) i64 { return switch (action) { @@ -145,6 +191,7 @@ fn semantics_spec(id: NodeKey, parent: NodeKey, role: SemanticRole, name: []u8) .parent = parent, .role = role, .name = name, + .description = "", .value = "", .action_mask = 0, .focus_order = 0, @@ -160,6 +207,21 @@ fn semantics_spec(id: NodeKey, parent: NodeKey, role: SemanticRole, name: []u8) .text_length = 0, .selection_start = 0, .selection_end = 0, + .has_bounds = 0, + .bounds = rect(0, 0, 0, 0), + .labelled_by = semantic_none_key(), + .described_by = semantic_none_key(), + .controls = semantic_none_key(), + .row_count = 0 - 1, + .column_count = 0 - 1, + .row_index = 0 - 1, + .column_index = 0 - 1, + .row_span = 0, + .column_span = 0, + .level = 0, + .set_size = 0, + .position_in_set = 0, + .expanded = 0 - 1, }; } @@ -192,6 +254,66 @@ fn semantic_text_equal(actual: ArrayList[u8], expected: []u8) i32 { return 1; } +fn semantic_evidence_mix(hash: i64, value: i64) i64 { + return (hash * 1099511628211) ^ value; +} + +fn semantic_text_evidence_hash(hash: i64, text: ArrayList[u8]) i64 { + let result: i64 = semantic_evidence_mix(hash, text.len as i64); + let i: i32 = 0; + while (i < text.len) { + result = semantic_evidence_mix(result, text.data[i] as i64); + i = i + 1; + } + return result; +} + +fn semantic_node_evidence_hash(node: SemanticsNode) i64 { + let hash: i64 = semantic_evidence_mix(17, node.id.value); + hash = semantic_evidence_mix(hash, node.id.generation); + hash = semantic_evidence_mix(hash, node.parent.value); + hash = semantic_evidence_mix(hash, node.parent.generation); + hash = semantic_evidence_mix(hash, node.role as i64); + hash = semantic_text_evidence_hash(hash, node.name); + hash = semantic_text_evidence_hash(hash, node.description); + hash = semantic_text_evidence_hash(hash, node.value); + hash = semantic_evidence_mix(hash, node.action_mask); + hash = semantic_evidence_mix(hash, node.focus_order); + hash = semantic_evidence_mix(hash, node.disabled as i64); + hash = semantic_evidence_mix(hash, node.selected as i64); + hash = semantic_evidence_mix(hash, node.hidden as i64); + hash = semantic_evidence_mix(hash, node.live_region as i64); + hash = semantic_evidence_mix(hash, node.has_range as i64); + hash = semantic_evidence_mix(hash, node.range_min); + hash = semantic_evidence_mix(hash, node.range_max); + hash = semantic_evidence_mix(hash, node.range_value); + hash = semantic_evidence_mix(hash, node.range_step); + hash = semantic_evidence_mix(hash, node.text_length); + hash = semantic_evidence_mix(hash, node.selection_start); + hash = semantic_evidence_mix(hash, node.selection_end); + hash = semantic_evidence_mix(hash, node.has_bounds as i64); + hash = semantic_evidence_mix(hash, node.bounds.x); + hash = semantic_evidence_mix(hash, node.bounds.y); + hash = semantic_evidence_mix(hash, node.bounds.width); + hash = semantic_evidence_mix(hash, node.bounds.height); + hash = semantic_evidence_mix(hash, node.labelled_by.value); + hash = semantic_evidence_mix(hash, node.labelled_by.generation); + hash = semantic_evidence_mix(hash, node.described_by.value); + hash = semantic_evidence_mix(hash, node.described_by.generation); + hash = semantic_evidence_mix(hash, node.controls.value); + hash = semantic_evidence_mix(hash, node.controls.generation); + hash = semantic_evidence_mix(hash, node.row_count); + hash = semantic_evidence_mix(hash, node.column_count); + hash = semantic_evidence_mix(hash, node.row_index); + hash = semantic_evidence_mix(hash, node.column_index); + hash = semantic_evidence_mix(hash, node.row_span); + hash = semantic_evidence_mix(hash, node.column_span); + hash = semantic_evidence_mix(hash, node.level); + hash = semantic_evidence_mix(hash, node.set_size); + hash = semantic_evidence_mix(hash, node.position_in_set); + return semantic_evidence_mix(hash, node.expanded as i64); +} + fn semantics_find_index(tree: SemanticsTree, id: NodeKey) i32 { let i: i32 = tree.nodes.len - 1; while (i >= 0) { @@ -207,7 +329,60 @@ fn semantics_fail(tree: *SemanticsTree, error: SemanticsError, id: NodeKey) Sema return error; } +fn semantics_relationship_valid(tree: SemanticsTree, owner: NodeKey, target: NodeKey) i32 { + if (node_key_equal(target, semantic_none_key()) == 1) { return 1; } + if (node_key_equal(owner, target) == 1) { return 0; } + return (semantics_find_index(tree, target) >= 0) as i32; +} + +fn semantics_bounds_valid(spec: SemanticsSpec) i32 { + if (spec.has_bounds == 0) { return 1; } + if (spec.has_bounds != 1 || spec.bounds.width < 0 || spec.bounds.height < 0) { return 0; } + if (spec.bounds.x < 0 - unbounded_extent() || spec.bounds.x > unbounded_extent() || + spec.bounds.y < 0 - unbounded_extent() || spec.bounds.y > unbounded_extent() || + spec.bounds.width > unbounded_extent() || spec.bounds.height > unbounded_extent()) { return 0; } + if (spec.bounds.x > unbounded_extent() - spec.bounds.width || + spec.bounds.y > unbounded_extent() - spec.bounds.height) { return 0; } + return 1; +} + +fn semantics_collection_valid(spec: SemanticsSpec) i32 { + if (spec.row_count < 0 - 1 || spec.column_count < 0 - 1 || + spec.row_index < 0 - 1 || spec.column_index < 0 - 1 || + spec.row_span < 0 || spec.column_span < 0 || spec.level < 0 || + spec.set_size < 0 || spec.position_in_set < 0 || + spec.expanded < 0 - 1 || spec.expanded > 1) { return 0; } + if ((spec.row_index < 0 && spec.row_span != 0) || + (spec.row_index >= 0 && spec.row_span <= 0) || + (spec.column_index < 0 && spec.column_span != 0) || + (spec.column_index >= 0 && spec.column_span <= 0)) { return 0; } + if (spec.row_count >= 0 && spec.row_index >= 0 && + (spec.row_index >= spec.row_count || spec.row_span > spec.row_count - spec.row_index)) { return 0; } + if (spec.column_count >= 0 && spec.column_index >= 0 && + (spec.column_index >= spec.column_count || spec.column_span > spec.column_count - spec.column_index)) { return 0; } + if ((spec.set_size == 0 && spec.position_in_set != 0) || + (spec.set_size > 0 && (spec.position_in_set <= 0 || spec.position_in_set > spec.set_size))) { return 0; } + return 1; +} + +fn semantics_state_valid(spec: SemanticsSpec) i32 { + if ((spec.disabled != 0 && spec.disabled != 1) || + (spec.selected != 0 && spec.selected != 1) || + (spec.hidden != 0 && spec.hidden != 1) || + (spec.has_range != 0 && spec.has_range != 1) || + spec.action_mask < 0 || spec.action_mask > 1023 || spec.focus_order < 0) { return 0; } + if (spec.has_range == 0 && (spec.range_min != 0 || spec.range_max != 0 || + spec.range_value != 0 || spec.range_step != 0)) { return 0; } + if (spec.has_bounds == 0 && (spec.bounds.x != 0 || spec.bounds.y != 0 || + spec.bounds.width != 0 || spec.bounds.height != 0)) { return 0; } + return 1; +} + fn semantics_add(tree: *SemanticsTree, spec: SemanticsSpec) SemanticsError { + if (node_key_equal(spec.id, semantic_root_key()) == 1 || + node_key_equal(spec.id, semantic_none_key()) == 1) { + return semantics_fail(tree, SemanticsError.invalid_id, spec.id); + } if (semantics_find_index(tree.*, spec.id) >= 0) { return semantics_fail(tree, SemanticsError.duplicate_id, spec.id); } @@ -233,12 +408,27 @@ fn semantics_add(tree: *SemanticsTree, spec: SemanticsSpec) SemanticsError { spec.selection_end < spec.selection_start || spec.selection_end > spec.text_length) { return semantics_fail(tree, SemanticsError.invalid_text_selection, spec.id); } + if (semantics_bounds_valid(spec) == 0) { + return semantics_fail(tree, SemanticsError.invalid_bounds, spec.id); + } + if (semantics_relationship_valid(tree.*, spec.id, spec.labelled_by) == 0 || + semantics_relationship_valid(tree.*, spec.id, spec.described_by) == 0 || + semantics_relationship_valid(tree.*, spec.id, spec.controls) == 0) { + return semantics_fail(tree, SemanticsError.invalid_relationship, spec.id); + } + if (semantics_collection_valid(spec) == 0) { + return semantics_fail(tree, SemanticsError.invalid_collection, spec.id); + } + if (semantics_state_valid(spec) == 0) { + return semantics_fail(tree, SemanticsError.invalid_state, spec.id); + } let node: SemanticsNode = SemanticsNode{ .id = spec.id, .parent = spec.parent, .role = spec.role, .name = semantic_text_copy(spec.name), + .description = semantic_text_copy(spec.description), .value = semantic_text_copy(spec.value), .action_mask = spec.action_mask, .focus_order = spec.focus_order, @@ -254,6 +444,21 @@ fn semantics_add(tree: *SemanticsTree, spec: SemanticsSpec) SemanticsError { .text_length = spec.text_length, .selection_start = spec.selection_start, .selection_end = spec.selection_end, + .has_bounds = spec.has_bounds, + .bounds = spec.bounds, + .labelled_by = spec.labelled_by, + .described_by = spec.described_by, + .controls = spec.controls, + .row_count = spec.row_count, + .column_count = spec.column_count, + .row_index = spec.row_index, + .column_index = spec.column_index, + .row_span = spec.row_span, + .column_span = spec.column_span, + .level = spec.level, + .set_size = spec.set_size, + .position_in_set = spec.position_in_set, + .expanded = spec.expanded, }; push[SemanticsNode](&tree.*.nodes, node); tree.*.revision = tree.*.revision + 1; @@ -284,8 +489,36 @@ fn semantics_tree_free(tree: *SemanticsTree) void { let i: i32 = 0; while (i < tree.*.nodes.len) { free[u8](&tree.*.nodes.data[i].name); + free[u8](&tree.*.nodes.data[i].description); free[u8](&tree.*.nodes.data[i].value); i = i + 1; } free[SemanticsNode](&tree.*.nodes); } + +fn semantics_tree_checkpoint(tree: SemanticsTree) SemanticsCheckpoint { + return SemanticsCheckpoint{ + .length = tree.nodes.len, + .revision = tree.revision, + .last_error = tree.last_error, + .error_node = tree.error_node, + }; +} + +fn semantics_tree_rollback(tree: *SemanticsTree, + checkpoint: SemanticsCheckpoint) i32 { + if (checkpoint.length < 0 || checkpoint.length > tree.*.nodes.len || + checkpoint.revision < 0 || checkpoint.revision > tree.*.revision) { return 0; } + let i: i32 = checkpoint.length; + while (i < tree.*.nodes.len) { + free[u8](&tree.*.nodes.data[i].name); + free[u8](&tree.*.nodes.data[i].description); + free[u8](&tree.*.nodes.data[i].value); + i = i + 1; + } + tree.*.nodes.len = checkpoint.length; + tree.*.revision = checkpoint.revision; + tree.*.last_error = checkpoint.last_error; + tree.*.error_node = checkpoint.error_node; + return 1; +} diff --git a/tests/collection_semantics_contract.zag b/tests/collection_semantics_contract.zag new file mode 100644 index 0000000..ee3602f --- /dev/null +++ b/tests/collection_semantics_contract.zag @@ -0,0 +1,151 @@ +@import("std:list") +@import("../src/semantics/collections.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn table_semantics_contract(state: *TestState) void { + let overrides: ArrayList[VirtualExtentOverride] = make[VirtualExtentOverride](1); + let rows: VirtualListModel = virtual_model(1000000, 1000, 20, 1, 3, overrides); + let columns: ArrayList[VirtualTableColumn] = make[VirtualTableColumn](4); + let pinned: VirtualTableColumn = virtual_table_column(10, 40, 30, 60); + pinned.pinned = 1; + push[VirtualTableColumn](&columns, pinned); + push[VirtualTableColumn](&columns, virtual_table_column(11, 50, 30, 80)); + push[VirtualTableColumn](&columns, virtual_table_column(12, 50, 30, 80)); + push[VirtualTableColumn](&columns, virtual_table_column(13, 50, 30, 80)); + let model: VirtualTableModel = virtual_table_model(7, rows, columns, 2); + let style: VirtualTableStyle = virtual_table_style(20, 2, insets_all(2)); + let scroll: i64 = virtual_item_offset(rows, + virtual_style(VirtualAxis.vertical, insets_all(0)), 500000); + let layout: VirtualTableResult = virtual_table_layout(model, size(180, 120), scroll, 20, style); + let tree: SemanticsTree = semantics_tree_make(); + let root_id: NodeKey = node_key_scoped(700, 2); + let projected: CollectionSemanticsResult = semantics_project_table( + &tree, layout, root_id, semantic_root_key(), "Parts" + ); + let root_index: i32 = semantics_find_index(tree, root_id); + expect(state, projected.error == CollectionSemanticsError.none && root_index >= 0 && + tree.nodes.data[root_index].row_count == 1000000 && + tree.nodes.data[root_index].column_count == 4, + "Table semantics retain full logical counts while materializing only live cells"); + expect(state, projected.live_items == layout.cells.len && + projected.nodes_added < 200 && tree.nodes.len == projected.nodes_added, + "Table semantic residency stays bounded by live layout output"); + + let target: VirtualTableCell = layout.cells.data[2]; + let target_id: NodeKey = semantic_table_cell_key(root_id, target.id); + let target_index: i32 = semantics_find_index(tree, target_id); + expect(state, target_index >= 0 && + tree.nodes.data[target_index].column_index == target.column_index as i64 && + tree.nodes.data[target_index].bounds.x == target.frame.x && + tree.nodes.data[target_index].has_bounds == 1, + "Table cells expose stable ID-first coordinates and exact layout geometry"); + expect(state, node_key_equal(target_id, semantic_table_cell_key(root_id, target.id)) == 1, + "Table cell semantic IDs are deterministic from authoritative composite identity"); + + let before: i32 = tree.nodes.len; + let duplicate: CollectionSemanticsResult = semantics_project_table( + &tree, layout, root_id, semantic_root_key(), "Parts" + ); + expect(state, duplicate.error == CollectionSemanticsError.identity_collision && + tree.nodes.len == before, + "Table identity collisions fail during preflight without partial tree mutation"); + + layout.cells.data[0].id.column_id = 999; + let malformed_tree: SemanticsTree = semantics_tree_make(); + let malformed: CollectionSemanticsResult = semantics_project_table( + &malformed_tree, layout, node_key(701), semantic_root_key(), "Malformed" + ); + expect(state, malformed.error == CollectionSemanticsError.invalid_result && + malformed.error_index == 0 && malformed_tree.nodes.len == 0, + "fabricated Table cell identity is rejected before semantic mutation"); + semantics_tree_free(&malformed_tree); + semantics_tree_free(&tree); + virtual_table_free(&layout); + free[VirtualTableColumn](&columns); + free[VirtualExtentOverride](&overrides); +} + +fn tree_source() ArrayList[VirtualTreeNode] { + let source: ArrayList[VirtualTreeNode] = make[VirtualTreeNode](5); + push[VirtualTreeNode](&source, virtual_tree_node(1, 0, 0, 1, 1, 12, 1)); + push[VirtualTreeNode](&source, virtual_tree_node(2, 1, 1, 0, 0, 10, 1)); + push[VirtualTreeNode](&source, virtual_tree_node(3, 1, 1, 1, 0, 10, 1)); + push[VirtualTreeNode](&source, virtual_tree_node(4, 3, 2, 0, 0, 10, 1)); + push[VirtualTreeNode](&source, virtual_tree_node(5, 0, 0, 0, 0, 10, 1)); + return source; +} + +fn tree_semantics_contract(state: *TestState) void { + let source: ArrayList[VirtualTreeNode] = tree_source(); + let projection: VirtualTreeProjection = virtual_tree_project(source, 1); + let style: VirtualTreeStyle = virtual_tree_style(10, 1, 4, insets_all(1)); + let layout: VirtualTreeResult = virtual_tree_layout(projection, size(100, 30), 0, style); + let tree: SemanticsTree = semantics_tree_make(); + let root_id: NodeKey = node_key_scoped(800, 4); + let projected: CollectionSemanticsResult = semantics_project_tree( + &tree, layout, root_id, semantic_root_key(), "Assembly" + ); + let root_index: i32 = semantics_find_index(tree, root_id); + expect(state, projected.error == CollectionSemanticsError.none && root_index >= 0 && + tree.nodes.data[root_index].row_count == layout.semantic_count && + projected.live_items == layout.placements.len, + "Tree semantics retain the full expanded projection count with bounded live nodes"); + + let expandable_id: NodeKey = semantic_tree_item_key(root_id, 3); + let expandable_index: i32 = semantics_find_index(tree, expandable_id); + expect(state, expandable_index >= 0 && + tree.nodes.data[expandable_index].level == 2 && + tree.nodes.data[expandable_index].expanded == 0 && + semantic_actions_has(tree.nodes.data[expandable_index].action_mask, + SemanticAction.expand) == 1, + "Tree items expose one-based level and truthful expansion action"); + let child_id: NodeKey = semantic_tree_item_key(root_id, 2); + let child_index: i32 = semantics_find_index(tree, child_id); + expect(state, child_index >= 0 && tree.nodes.data[child_index].position_in_set == 2 && + tree.nodes.data[child_index].bounds.x == layout.placements.data[1].frame.x, + "Tree semantic position and row bounds follow stable visible identity"); + + semantics_tree_free(&tree); + virtual_tree_free(&layout); + virtual_tree_projection_free(&projection); + free[VirtualTreeNode](&source); +} + +fn failure_contract(state: *TestState) void { + let tree: SemanticsTree = semantics_tree_make(); + let invalid: VirtualTreeResult = virtual_tree_result_error(VirtualTreeError.invalid_node, 4); + let result: CollectionSemanticsResult = semantics_project_tree( + &tree, invalid, node_key(90), semantic_root_key(), "Invalid" + ); + expect(state, result.error == CollectionSemanticsError.invalid_result && + result.error_index == 4 && tree.nodes.len == 0, + "invalid virtual layouts cannot leak partial semantic nodes"); + virtual_tree_free(&invalid); + semantics_tree_free(&tree); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + table_semantics_contract(&state); + tree_semantics_contract(&state); + failure_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Collection semantics contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); + _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tests/semantics_contract.zag b/tests/semantics_contract.zag index 47243d3..a8b6134 100644 --- a/tests/semantics_contract.zag +++ b/tests/semantics_contract.zag @@ -25,6 +25,9 @@ fn tree_contract(state: *TestState) void { save.focus_order = 2; save.selected = 1; save.live_region = LiveRegion.polite; + save.description = "Writes the current document"; + save.has_bounds = 1; + save.bounds = rect(8, 16, 96, 32); expect(state, semantics_add(&tree, save) == SemanticsError.none, "a named actionable child is accepted"); let field_id: NodeKey = node_key(3); @@ -46,16 +49,19 @@ fn tree_contract(state: *TestState) void { slider.range_max = 400; slider.range_value = 100; slider.range_step = 25; + slider.described_by = save_id; expect(state, semantics_add(&tree, slider) == SemanticsError.none, "range semantics preserve value and step"); let save_index: i32 = semantics_find_index(tree, save_id); let field_index: i32 = semantics_find_index(tree, field_id); let slider_index: i32 = semantics_find_index(tree, slider_id); expect(state, save_index >= 0 && semantic_text_equal(tree.nodes.data[save_index].name, "Save") == 1, "owned semantic names are queryable by stable ID"); + expect(state, semantic_text_equal(tree.nodes.data[save_index].description, "Writes the current document") == 1 && tree.nodes.data[save_index].bounds.width == 96, "owned descriptions and exact logical bounds are inspectable"); expect(state, semantic_actions_has(tree.nodes.data[save_index].action_mask, SemanticAction.activate) == 1 && semantic_actions_has(tree.nodes.data[save_index].action_mask, SemanticAction.scroll) == 0, "semantic actions are explicit capabilities"); expect(state, tree.nodes.data[save_index].selected == 1 && tree.nodes.data[save_index].live_region == LiveRegion.polite, "selection and live-region state remain explicit"); expect(state, field_index >= 0 && semantic_text_equal(tree.nodes.data[field_index].value, "Bracket") == 1 && tree.nodes.data[field_index].selection_end == 7, "text value and selection remain inspectable"); expect(state, slider_index >= 0 && tree.nodes.data[slider_index].range_value == 100 && tree.nodes.data[slider_index].range_step == 25, "range value remains inspectable"); + expect(state, node_key_equal(tree.nodes.data[slider_index].described_by, save_id) == 1, "semantic relationships retain their stable target ID"); expect(state, node_key_equal(semantics_next_focus(tree, 0), field_id) == 1 && node_key_equal(semantics_next_focus(tree, 1), save_id) == 1, "focus order is deterministic rather than insertion based"); let disabled: SemanticsSpec = semantics_spec(node_key(5), root_id, SemanticRole.button, "Unavailable"); disabled.action_mask = semantic_actions(SemanticAction.focus); @@ -76,6 +82,10 @@ fn rejection_contract(state: *TestState) void { let duplicate: SemanticsSpec = semantics_spec(root_id, semantic_root_key(), SemanticRole.button, "Duplicate"); expect(state, semantics_add(&tree, duplicate) == SemanticsError.duplicate_id && tree.nodes.len == 1, "duplicate IDs fail without mutating the tree"); + let reserved: SemanticsSpec = semantics_spec(semantic_root_key(), semantic_root_key(), SemanticRole.button, "Reserved"); + expect(state, semantics_add(&tree, reserved) == SemanticsError.invalid_id && tree.nodes.len == 1, + "reserved sentinel IDs cannot masquerade as semantic nodes"); + let orphan: SemanticsSpec = semantics_spec(node_key(11), node_key(999), SemanticRole.button, "Orphan"); expect(state, semantics_add(&tree, orphan) == SemanticsError.missing_parent && node_key_equal(tree.error_node, node_key(11)) == 1, "missing parents report the exact rejected node"); @@ -99,6 +109,53 @@ fn rejection_contract(state: *TestState) void { bad_text.selection_start = 3; bad_text.selection_end = 5; expect(state, semantics_add(&tree, bad_text) == SemanticsError.invalid_text_selection, "out-of-bounds text selection is rejected"); + + let bad_bounds: SemanticsSpec = semantics_spec(node_key(16), root_id, SemanticRole.button, "Bad bounds"); + bad_bounds.has_bounds = 1; + bad_bounds.bounds = rect(0, 0, 0 - 1, 10); + expect(state, semantics_add(&tree, bad_bounds) == SemanticsError.invalid_bounds, "negative semantic geometry is rejected without mutation"); + + let dangling: SemanticsSpec = semantics_spec(node_key(17), root_id, SemanticRole.button, "Dangling relation"); + dangling.labelled_by = node_key(999); + expect(state, semantics_add(&tree, dangling) == SemanticsError.invalid_relationship, "dangling semantic relationships fail closed"); + + let bad_collection: SemanticsSpec = semantics_spec(node_key(18), root_id, SemanticRole.cell, "Bad cell"); + bad_collection.row_index = 4; + bad_collection.row_span = 0; + expect(state, semantics_add(&tree, bad_collection) == SemanticsError.invalid_collection, "collection indices require a positive span"); + + let bad_state: SemanticsSpec = semantics_spec(node_key(19), root_id, SemanticRole.button, "Bad state"); + bad_state.selected = 2; + expect(state, semantics_add(&tree, bad_state) == SemanticsError.invalid_state, + "noncanonical semantic flags fail instead of leaking ambiguous state"); + semantics_tree_free(&tree); +} + +fn collection_metadata_contract(state: *TestState) void { + let tree: SemanticsTree = semantics_tree_make(); + let table_id: NodeKey = node_key(30); + let table: SemanticsSpec = semantics_spec(table_id, semantic_root_key(), SemanticRole.table, "Parts"); + table.row_count = 1000000; + table.column_count = 12; + table.has_bounds = 1; + table.bounds = rect(0, 0, 1200, 800); + expect(state, semantics_add(&tree, table) == SemanticsError.none, "virtual collection roots retain full logical row and column counts"); + + let cell: SemanticsSpec = semantics_spec(node_key(31), table_id, SemanticRole.cell, "Mass"); + cell.row_index = 999999; + cell.column_index = 11; + cell.row_span = 1; + cell.column_span = 1; + expect(state, semantics_add(&tree, cell) == SemanticsError.none, "live cells retain zero-based logical collection coordinates"); + + let item: SemanticsSpec = semantics_spec(node_key(32), table_id, SemanticRole.tree_item, "Assembly"); + item.level = 3; + item.set_size = 1000000; + item.position_in_set = 500001; + item.expanded = 1; + expect(state, semantics_add(&tree, item) == SemanticsError.none, "tree level, set position, and expansion truth are explicit"); + let index: i32 = semantics_find_index(tree, node_key(32)); + expect(state, index >= 0 && tree.nodes.data[index].level == 3 && tree.nodes.data[index].expanded == 1, "tree metadata remains inspectable after ownership transfer"); semantics_tree_free(&tree); } @@ -126,11 +183,27 @@ fn generated_identity_contract(state: *TestState) void { expect(state, semantic_generated_key(parent, SemanticRole.button, 7).generation == 3, "generated IDs preserve parent scope generation"); } +fn truncate_contract(state: *TestState) void { + let tree: SemanticsTree = semantics_tree_make(); + _ = semantics_add(&tree, semantics_spec(node_key(60), semantic_root_key(), SemanticRole.window, "Root")); + let checkpoint: SemanticsCheckpoint = semantics_tree_checkpoint(tree); + _ = semantics_add(&tree, semantics_spec(node_key(61), node_key(60), SemanticRole.button, "Temporary")); + let rolled_back: i32 = semantics_tree_rollback(&tree, checkpoint); + expect(state, rolled_back == 1 && tree.nodes.len == 1 && + tree.revision == checkpoint.revision && + semantics_find_index(tree, node_key(61)) < 0 && + tree.last_error == SemanticsError.none, + "semantic transactions can roll back owned nodes and exact revision truth"); + semantics_tree_free(&tree); +} + fn main() i32 { let state: TestState = TestState{ .passed = 0, .failed = 0 }; tree_contract(&state); rejection_contract(&state); + collection_metadata_contract(&state); generated_identity_contract(&state); + truncate_contract(&state); let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); _zag_print("Semantics contract: pass="); _zag_print(passed_text); diff --git a/tests/talkback_contract.zag b/tests/talkback_contract.zag index 88ee172..ed8a5b2 100644 --- a/tests/talkback_contract.zag +++ b/tests/talkback_contract.zag @@ -18,6 +18,10 @@ fn make_tree() SemanticsTree { _ = semantics_add(&tree, root); let button: SemanticsSpec = semantics_spec(node_key(2), node_key(1), SemanticRole.button, "Create part"); button.action_mask = semantic_actions_add(semantic_actions(SemanticAction.activate), SemanticAction.focus); + button.description = "Creates a part in the active document"; + button.has_bounds = 1; + button.bounds = rect(10, 20, 120, 32); + button.selected = 1; _ = semantics_add(&tree, button); let field: SemanticsSpec = semantics_spec(node_key(3), node_key(1), SemanticRole.text_field, "Part name"); field.action_mask = semantic_actions_add(semantic_actions(SemanticAction.set_value), SemanticAction.focus); @@ -37,6 +41,13 @@ fn id_target_contract(state: *TestState) void { let type_button: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(4, TalkbackCommand.type_text, node_key(2), tree.revision)); expect(state, query.status == TalkbackStatus.accepted && click.status == TalkbackStatus.accepted && type_field.status == TalkbackStatus.accepted, "ID queries and declared semantic actions are accepted"); expect(state, query.resolved_node_index == semantics_find_index(tree, node_key(2)) && query.result_count == 1, "ID queries resolve to an inspectable semantic node"); + expect(state, query.resolved_role == SemanticRole.button && query.has_bounds == 1 && + query.bounds.x == 10 && query.bounds.width == 120 && query.action_mask == + semantic_actions_add(semantic_actions(SemanticAction.activate), SemanticAction.focus), + "Talkback query evidence exposes role, exact bounds, and declared actions"); + expect(state, query.name_length == 11 && query.description_length == 37 && + query.value_length == 0 && query.state_flags == 2 && query.evidence_hash != 0, + "Talkback query evidence fingerprints owned text and explicit state"); expect(state, click.emitted_action == 1 && query.emitted_action == 0, "responses distinguish emitted actions from read-only queries"); expect(state, type_button.status == TalkbackStatus.action_unavailable, "an ID action absent from semantics fails visibly"); @@ -95,7 +106,7 @@ fn capability_contract(state: *TestState) void { expect(state, report.status == TalkbackStatus.accepted, "capability reporting is always queryable"); expect(state, screenshot.status == TalkbackStatus.unsupported_command && replay.status == TalkbackStatus.unsupported_command, "unavailable screenshot and replay capabilities fail closed"); expect(state, assertion.status == TalkbackStatus.unsupported_command, "assertions remain unavailable until payload comparison is implemented"); - expect(state, capabilities.protocol_major == 0 && capabilities.protocol_minor == 1, "the experimental protocol version is explicit"); + expect(state, capabilities.protocol_major == 0 && capabilities.protocol_minor == 2, "the experimental protocol version is explicit"); talkback_session_free(&session); semantics_tree_free(&tree); } diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 53813b5..e80e56f 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -32,6 +32,8 @@ cd "$root" "$tmp/state-reconcile-contract" "$znc" tests/semantics_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/semantics-contract" "$tmp/semantics-contract" +"$znc" tests/collection_semantics_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/collection-semantics-contract" +"$tmp/collection-semantics-contract" "$znc" tests/talkback_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/talkback-contract" "$tmp/talkback-contract" "$znc" tests/display_list_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/display-list-contract" @@ -47,4 +49,4 @@ cd "$root" "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, semantics, Talkback, display lists, CPU raster, input, replay, and motion)\n' +printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, display lists, CPU raster, input, replay, and motion)\n' From ba95333bf05875f7abffa1fb5257fbeb03edb8e7 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 09:00:32 -0700 Subject: [PATCH 022/103] Add bounded render resource ownership --- CHANGELOG.md | 3 + README.md | 7 +- docs/architecture/README.md | 7 + docs/architecture/render-resources.md | 52 ++++ src/render/resources.zag | 357 ++++++++++++++++++++++++++ tests/render_resources_contract.zag | 142 ++++++++++ tools/test-headless.sh | 4 +- 7 files changed, 569 insertions(+), 3 deletions(-) create mode 100644 docs/architecture/render-resources.md create mode 100644 src/render/resources.zag create mode 100644 tests/render_resources_contract.zag diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ec6aae..27b60dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,9 @@ include a generated capability record and exact Zag compiler revision. with protocol 0.2 query evidence for semantic geometry, collection metadata, tree state, owned-text lengths, state flags, deterministic hashes, ID-first targeting, and explicit scale-aware pixel fallback. +- A bounded owned render-resource store with typed stable IDs, canonical order, + exact replacement revisions, copied payloads, byte accounting, sealing, + mutation detection, and explicit decoder and serialization boundaries. - An experimental immutable display list for explicit paints, resources, clips, transforms, layers, and effects with deterministic content identity. - A versioned canonical display-list codec with bounded validated decoding and diff --git a/README.md b/README.md index 20ddae5..9d9e9ef 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,11 @@ fixed-point geometry, RGBA16 paints, paths, images, glyph runs, layers, and effects as explicit operations. Invalid geometry, resources, parameters, and stack balance fail before mutation; the builder rejects writes after sealing, and verification detects out-of-contract raw mutation against deterministic -content identity. Path/resource storage, resource serialization, damage, CPU -rasterization, and GPU transport remain open. The first versioned binary codec +content identity. A separate experimental resource store now owns opaque typed +payload bytes with stable IDs, canonical order, bounded allocation, exact +replacement revisions, sealing, and mutation verification. It is not yet +attached to the display list, so payload schemas, resource serialization, +damage, complete CPU rasterization, and GPU transport remain open. The first versioned binary codec now round-trips sealed lists byte-identically and rejects malformed, truncated, noncanonical, unknown-version, and hash-mismatched input; schema evolution, resource payloads, and fuzz coverage remain open. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index ddb1c39..e5a7aae 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -71,6 +71,13 @@ Balanced lists seal with deterministic content identity, and verification detects mutation outside the builder contract before rendering. Resource storage, serialization, damage, the CPU oracle, and GPU transports remain open. +The first [render resource ownership layer](render-resources.md) copies opaque +typed payloads behind positive stable IDs, canonicalizes insertion order, +enforces configurable byte and count ceilings, requires exact replacement +revisions, and verifies sealed byte-level identity. It is not yet attached to +the display list and does not claim path, SVG, PNG, font, image, or glyph +decoding. + Display-list replay uses the versioned little-endian `ZKDL` codec. Decoding is bounded to one million operations, reconstructs operations only through the same validation path as live building, requires balanced seal state, verifies diff --git a/docs/architecture/render-resources.md b/docs/architecture/render-resources.md new file mode 100644 index 0000000..176825a --- /dev/null +++ b/docs/architecture/render-resources.md @@ -0,0 +1,52 @@ +# Render resource ownership + +Status: experimental headless primitive + +`RenderResourceStore` is the ownership and identity boundary for bytes that +will later feed Zagkit paths, decoded images, glyph runs, effects, fonts, SVG, +and PNG. It is not a decoder and does not make an opaque payload renderable. +SVG parsing, PNG parsing and color management, font validation, glyph shaping, +path schemas, and display-list payload serialization remain separate open +contracts. + +## Identity and metadata + +Every resource has a positive stable ID, one explicit kind, a positive format +tag, revision, owned byte payload, optional dimensions, and color-space truth. +Decoded image resources require positive dimensions and a nonempty color space. +The format tag identifies a caller-defined canonical payload schema; it is not +interpreted by this layer. + +New IDs begin at revision zero. Replacement retains the same ID and kind and +must advance exactly one revision. Missing replacement targets, stale or +skipped revisions, and kind changes fail before the old payload is released. +Payload bytes are copied at successful add or replacement, so callers retain no +borrowed lifetime obligation. + +## Bounds and canonical order + +Each store declares resource-count, total-byte, and per-payload ceilings within +hard library maxima. Configuration, empty payload, metadata, per-payload, +aggregate, duplicate-ID, and count errors are checked before ownership changes. +Tests use intentionally small limits to prove each failure without expensive +allocations. + +Resources are retained in stable-ID order regardless of insertion order. +Content identity covers configuration, byte accounting, store revision, +resource metadata, per-resource revision, and every payload byte. Two stores +with the same resources therefore have the same identity even when populated +in different orders. + +## Immutability and cleanup + +Sealing first verifies the complete store. A sealed store rejects add and +replacement. Verification detects order changes, byte mutations, metadata or +revision changes, forged accounting, invalid limits, and aggregate identity +changes. `render_resource_store_free` releases every owned payload and then the +resource array; callers invoke it exactly once for every store, sealed or not. + +The current executable contract does not attach this store to `DisplayList`, +serialize payloads in `ZKDL`, decode SVG or PNG, validate font data, rasterize +paths or images, cache platform uploads, or implement memory-pressure eviction. +Those capabilities remain unavailable until their own malformed-input, +round-trip, rendering, replacement, and cleanup suites pass. diff --git a/src/render/resources.zag b/src/render/resources.zag new file mode 100644 index 0000000..c056702 --- /dev/null +++ b/src/render/resources.zag @@ -0,0 +1,357 @@ +@import("std:list") + +enum RenderResourceKind { + path, + image, + glyph_run, + effect, + font_data, + svg_source, + png_source, +} + +enum RenderColorSpace { + none, + srgb, + display_p3, + linear_srgb, +} + +enum RenderResourceError { + none, + invalid_configuration, + invalid_id, + invalid_payload, + invalid_metadata, + duplicate_id, + missing_resource, + kind_mismatch, + revision_conflict, + too_many_resources, + payload_limit, + total_limit, + sealed, + not_sealed, + content_mutated, +} + +struct RenderResourceSpec { + id: i64, + kind: RenderResourceKind, + payload: []u8, + revision: i64, + format_tag: i64, + width: i64, + height: i64, + color_space: RenderColorSpace, +} + +struct RenderResource { + id: i64, + kind: RenderResourceKind, + payload: ArrayList[u8], + revision: i64, + format_tag: i64, + width: i64, + height: i64, + color_space: RenderColorSpace, + content_hash: i64, +} + +struct RenderResourceStore { + resources: ArrayList[RenderResource], + max_resources: i32, + max_total_bytes: i64, + max_payload_bytes: i64, + total_bytes: i64, + revision: i64, + content_hash: i64, + sealed: i32, + last_error: RenderResourceError, + error_id: i64, +} + +fn render_resource_hard_count_limit() i32 { return 65536; } +fn render_resource_hard_total_limit() i64 { return 536870912; } +fn render_resource_hard_payload_limit() i64 { return 67108864; } + +fn render_resource_mix(hash: i64, value: i64) i64 { + let modulus: i64 = 2147483647; + let component: i64 = value % modulus; + if (component < 0) { component = component + modulus; } + return (hash * 131 + component) % modulus; +} + +fn render_resource_hash_bytes(hash: i64, payload: ArrayList[u8]) i64 { + let result: i64 = render_resource_mix(hash, payload.len as i64); + let i: i32 = 0; + while (i < payload.len) { + result = render_resource_mix(result, payload.data[i] as i64); + i = i + 1; + } + return result; +} + +fn render_resource_hash(resource: RenderResource) i64 { + let hash: i64 = render_resource_mix(17, resource.id); + hash = render_resource_mix(hash, resource.kind as i64); + hash = render_resource_mix(hash, resource.revision); + hash = render_resource_mix(hash, resource.format_tag); + hash = render_resource_mix(hash, resource.width); + hash = render_resource_mix(hash, resource.height); + hash = render_resource_mix(hash, resource.color_space as i64); + return render_resource_hash_bytes(hash, resource.payload); +} + +fn render_resource_store_hash(store: RenderResourceStore) i64 { + let hash: i64 = render_resource_mix(17, store.resources.len as i64); + hash = render_resource_mix(hash, store.max_resources as i64); + hash = render_resource_mix(hash, store.max_total_bytes); + hash = render_resource_mix(hash, store.max_payload_bytes); + hash = render_resource_mix(hash, store.total_bytes); + hash = render_resource_mix(hash, store.revision); + let i: i32 = 0; + while (i < store.resources.len) { + hash = render_resource_mix(hash, store.resources.data[i].content_hash); + i = i + 1; + } + return hash; +} + +fn render_resource_store_make(max_resources: i32, max_total_bytes: i64, + max_payload_bytes: i64) RenderResourceStore { + let store: RenderResourceStore = RenderResourceStore{ + .resources = make[RenderResource](8), + .max_resources = max_resources, + .max_total_bytes = max_total_bytes, + .max_payload_bytes = max_payload_bytes, + .total_bytes = 0, + .revision = 0, + .content_hash = 0, + .sealed = 0, + .last_error = RenderResourceError.none, + .error_id = 0, + }; + store.content_hash = render_resource_store_hash(store); + return store; +} + +fn render_resource_store_default() RenderResourceStore { + return render_resource_store_make(4096, 268435456, 33554432); +} + +fn render_resource_store_configuration_valid(store: RenderResourceStore) i32 { + return (store.max_resources > 0 && + store.max_resources <= render_resource_hard_count_limit() && + store.max_total_bytes > 0 && + store.max_total_bytes <= render_resource_hard_total_limit() && + store.max_payload_bytes > 0 && + store.max_payload_bytes <= render_resource_hard_payload_limit() && + store.max_payload_bytes <= store.max_total_bytes) as i32; +} + +fn render_resource_spec(id: i64, kind: RenderResourceKind, + payload: []u8, format_tag: i64) RenderResourceSpec { + return RenderResourceSpec{ + .id = id, .kind = kind, .payload = payload, .revision = 0, + .format_tag = format_tag, .width = 0, .height = 0, + .color_space = RenderColorSpace.none, + }; +} + +fn render_resource_fail(store: *RenderResourceStore, + error: RenderResourceError, id: i64) RenderResourceError { + store.*.last_error = error; + store.*.error_id = id; + return error; +} + +fn render_resource_spec_valid(spec: RenderResourceSpec) RenderResourceError { + if (spec.id <= 0) { return RenderResourceError.invalid_id; } + if (spec.payload.len <= 0) { return RenderResourceError.invalid_payload; } + if (spec.revision < 0 || spec.format_tag <= 0 || spec.width < 0 || + spec.height < 0 || spec.width > 1048576 || spec.height > 1048576) { + return RenderResourceError.invalid_metadata; + } + if (spec.kind == RenderResourceKind.image && + (spec.width <= 0 || spec.height <= 0 || + spec.color_space == RenderColorSpace.none)) { + return RenderResourceError.invalid_metadata; + } + return RenderResourceError.none; +} + +fn render_resource_payload_copy(payload: []u8) ArrayList[u8] { + let owned: ArrayList[u8] = make[u8](payload.len); + let i: i32 = 0; + while (i < payload.len) { + push[u8](&owned, payload[i]); + i = i + 1; + } + return owned; +} + +fn render_resource_from_spec(spec: RenderResourceSpec) RenderResource { + let resource: RenderResource = RenderResource{ + .id = spec.id, .kind = spec.kind, + .payload = render_resource_payload_copy(spec.payload), + .revision = spec.revision, .format_tag = spec.format_tag, + .width = spec.width, .height = spec.height, + .color_space = spec.color_space, .content_hash = 0, + }; + resource.content_hash = render_resource_hash(resource); + return resource; +} + +fn render_resource_find_index(store: RenderResourceStore, id: i64) i32 { + let low: i32 = 0; + let high: i32 = store.resources.len - 1; + while (low <= high) { + let middle: i32 = low + (high - low) / 2; + let candidate: i64 = store.resources.data[middle].id; + if (candidate == id) { return middle; } + if (candidate < id) { low = middle + 1; } else { high = middle - 1; } + } + return 0 - 1; +} + +fn render_resource_insert_index(store: RenderResourceStore, id: i64) i32 { + let i: i32 = 0; + while (i < store.resources.len && store.resources.data[i].id < id) { i = i + 1; } + return i; +} + +fn render_resource_store_rehash(store: *RenderResourceStore) void { + store.*.content_hash = render_resource_store_hash(store.*); +} + +fn render_resource_add(store: *RenderResourceStore, + spec: RenderResourceSpec) RenderResourceError { + if (store.*.sealed != 0) { + return render_resource_fail(store, RenderResourceError.sealed, spec.id); + } + if (render_resource_store_configuration_valid(store.*) == 0) { + return render_resource_fail(store, RenderResourceError.invalid_configuration, spec.id); + } + let valid: RenderResourceError = render_resource_spec_valid(spec); + if (valid != RenderResourceError.none) { return render_resource_fail(store, valid, spec.id); } + if (spec.revision != 0) { + return render_resource_fail(store, RenderResourceError.revision_conflict, spec.id); + } + if (render_resource_find_index(store.*, spec.id) >= 0) { + return render_resource_fail(store, RenderResourceError.duplicate_id, spec.id); + } + if (store.*.resources.len >= store.*.max_resources) { + return render_resource_fail(store, RenderResourceError.too_many_resources, spec.id); + } + if (spec.payload.len as i64 > store.*.max_payload_bytes) { + return render_resource_fail(store, RenderResourceError.payload_limit, spec.id); + } + if (spec.payload.len as i64 > store.*.max_total_bytes - store.*.total_bytes) { + return render_resource_fail(store, RenderResourceError.total_limit, spec.id); + } + + let resource: RenderResource = render_resource_from_spec(spec); + let index: i32 = render_resource_insert_index(store.*, spec.id); + push[RenderResource](&store.*.resources, resource); + let i: i32 = store.*.resources.len - 1; + while (i > index) { + store.*.resources.data[i] = store.*.resources.data[i - 1]; + i = i - 1; + } + store.*.resources.data[index] = resource; + store.*.total_bytes = store.*.total_bytes + spec.payload.len as i64; + store.*.revision = store.*.revision + 1; + render_resource_store_rehash(store); + store.*.last_error = RenderResourceError.none; + store.*.error_id = 0; + return RenderResourceError.none; +} + +fn render_resource_replace(store: *RenderResourceStore, + spec: RenderResourceSpec) RenderResourceError { + if (store.*.sealed != 0) { + return render_resource_fail(store, RenderResourceError.sealed, spec.id); + } + if (render_resource_store_configuration_valid(store.*) == 0) { + return render_resource_fail(store, RenderResourceError.invalid_configuration, spec.id); + } + let valid: RenderResourceError = render_resource_spec_valid(spec); + if (valid != RenderResourceError.none) { return render_resource_fail(store, valid, spec.id); } + let index: i32 = render_resource_find_index(store.*, spec.id); + if (index < 0) { + return render_resource_fail(store, RenderResourceError.missing_resource, spec.id); + } + let old: RenderResource = store.*.resources.data[index]; + if (old.kind != spec.kind) { + return render_resource_fail(store, RenderResourceError.kind_mismatch, spec.id); + } + if (spec.revision <= old.revision || spec.revision - old.revision != 1) { + return render_resource_fail(store, RenderResourceError.revision_conflict, spec.id); + } + if (spec.payload.len as i64 > store.*.max_payload_bytes) { + return render_resource_fail(store, RenderResourceError.payload_limit, spec.id); + } + let next_total: i64 = store.*.total_bytes - old.payload.len as i64 + spec.payload.len as i64; + if (next_total > store.*.max_total_bytes) { + return render_resource_fail(store, RenderResourceError.total_limit, spec.id); + } + let resource: RenderResource = render_resource_from_spec(spec); + free[u8](&store.*.resources.data[index].payload); + store.*.resources.data[index] = resource; + store.*.total_bytes = next_total; + store.*.revision = store.*.revision + 1; + render_resource_store_rehash(store); + store.*.last_error = RenderResourceError.none; + store.*.error_id = 0; + return RenderResourceError.none; +} + +fn render_resource_store_seal(store: *RenderResourceStore) RenderResourceError { + if (store.*.sealed != 0) { return RenderResourceError.none; } + if (render_resource_store_configuration_valid(store.*) == 0) { + return render_resource_fail(store, RenderResourceError.invalid_configuration, 0); + } + store.*.sealed = 1; + if (render_resource_store_verify(store.*) != RenderResourceError.none) { + store.*.sealed = 0; + return render_resource_fail(store, RenderResourceError.content_mutated, 0); + } + store.*.last_error = RenderResourceError.none; + store.*.error_id = 0; + return RenderResourceError.none; +} + +fn render_resource_store_verify(store: RenderResourceStore) RenderResourceError { + if (store.sealed == 0) { return RenderResourceError.not_sealed; } + if (render_resource_store_configuration_valid(store) == 0 || + store.resources.len > store.max_resources) { return RenderResourceError.content_mutated; } + let total: i64 = 0; + let i: i32 = 0; + while (i < store.resources.len) { + let resource: RenderResource = store.resources.data[i]; + if (resource.id <= 0 || resource.payload.len <= 0 || + resource.payload.len as i64 > store.max_payload_bytes || + (i > 0 && store.resources.data[i - 1].id >= resource.id) || + render_resource_hash(resource) != resource.content_hash) { + return RenderResourceError.content_mutated; + } + total = total + resource.payload.len as i64; + if (total > store.max_total_bytes) { return RenderResourceError.content_mutated; } + i = i + 1; + } + if (total != store.total_bytes || store.revision < store.resources.len as i64 || + render_resource_store_hash(store) != store.content_hash) { + return RenderResourceError.content_mutated; + } + return RenderResourceError.none; +} + +fn render_resource_store_free(store: *RenderResourceStore) void { + let i: i32 = 0; + while (i < store.*.resources.len) { + free[u8](&store.*.resources.data[i].payload); + i = i + 1; + } + free[RenderResource](&store.*.resources); +} diff --git a/tests/render_resources_contract.zag b/tests/render_resources_contract.zag new file mode 100644 index 0000000..11b3b57 --- /dev/null +++ b/tests/render_resources_contract.zag @@ -0,0 +1,142 @@ +@import("../src/render/resources.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn ownership_contract(state: *TestState) void { + let store: RenderResourceStore = render_resource_store_make(8, 128, 64); + let path: RenderResourceSpec = render_resource_spec(20, RenderResourceKind.path, "PATH-A", 1); + let image: RenderResourceSpec = render_resource_spec(10, RenderResourceKind.image, "RGBA", 2); + image.width = 1; image.height = 1; image.color_space = RenderColorSpace.srgb; + expect(state, render_resource_add(&store, path) == RenderResourceError.none && + render_resource_add(&store, image) == RenderResourceError.none, + "resource store accepts valid typed payloads with explicit metadata"); + expect(state, store.resources.len == 2 && store.resources.data[0].id == 10 && + store.resources.data[1].id == 20 && store.total_bytes == 10, + "owned resources are kept in canonical stable-ID order with exact byte accounting"); + expect(state, store.resources.data[1].payload.len == 6 && + store.resources.data[1].payload.data[5] == 65, + "resource payload bytes are copied into store ownership"); + expect(state, render_resource_find_index(store, 10) == 0 && + render_resource_find_index(store, 20) == 1 && + render_resource_find_index(store, 99) < 0, + "binary lookup resolves exact IDs without fabricating missing resources"); + + let before_hash: i64 = store.content_hash; + let replacement: RenderResourceSpec = render_resource_spec(20, RenderResourceKind.path, "PATH-BB", 1); + replacement.revision = 1; + expect(state, render_resource_replace(&store, replacement) == RenderResourceError.none && + store.resources.data[1].revision == 1 && store.total_bytes == 11 && + store.content_hash != before_hash, + "revisioned replacement preserves identity and updates owned bytes and content truth"); + let stale: RenderResourceSpec = replacement; stale.revision = 1; + expect(state, render_resource_replace(&store, stale) == RenderResourceError.revision_conflict && + store.resources.data[1].payload.len == 7, + "stale replacement fails before payload mutation"); + let wrong_kind: RenderResourceSpec = render_resource_spec(20, RenderResourceKind.svg_source, "", 3); + wrong_kind.revision = 2; + expect(state, render_resource_replace(&store, wrong_kind) == RenderResourceError.kind_mismatch, + "resource identity cannot silently change kind during replacement"); + + expect(state, render_resource_store_seal(&store) == RenderResourceError.none && + render_resource_store_verify(store) == RenderResourceError.none, + "sealed resource stores verify complete owned content identity"); + expect(state, render_resource_add(&store, render_resource_spec(30, + RenderResourceKind.effect, "FX", 1)) == RenderResourceError.sealed, + "sealed resource stores reject further mutation"); + store.resources.data[0].payload.data[0] = store.resources.data[0].payload.data[0] + 1; + expect(state, render_resource_store_verify(store) == RenderResourceError.content_mutated, + "out-of-contract payload mutation is detected before rendering"); + render_resource_store_free(&store); +} + +fn canonical_contract(state: *TestState) void { + let forward: RenderResourceStore = render_resource_store_make(4, 64, 32); + let reverse: RenderResourceStore = render_resource_store_make(4, 64, 32); + let one: RenderResourceSpec = render_resource_spec(1, RenderResourceKind.svg_source, "", 10); + let two: RenderResourceSpec = render_resource_spec(2, RenderResourceKind.png_source, "PNG", 11); + _ = render_resource_add(&forward, one); _ = render_resource_add(&forward, two); + _ = render_resource_add(&reverse, two); _ = render_resource_add(&reverse, one); + expect(state, forward.content_hash == reverse.content_hash && + forward.resources.data[0].id == reverse.resources.data[0].id, + "insertion order cannot change canonical resource identity"); + _ = render_resource_store_seal(&forward); _ = render_resource_store_seal(&reverse); + expect(state, render_resource_store_verify(forward) == RenderResourceError.none && + render_resource_store_verify(reverse) == RenderResourceError.none, + "canonical stores remain independently verifiable after sealing"); + render_resource_store_free(&reverse); render_resource_store_free(&forward); +} + +fn failure_contract(state: *TestState) void { + let invalid: RenderResourceStore = render_resource_store_make(0, 8, 8); + expect(state, render_resource_add(&invalid, render_resource_spec(1, + RenderResourceKind.path, "A", 1)) == RenderResourceError.invalid_configuration, + "invalid allocation policy fails before payload ownership"); + render_resource_store_free(&invalid); + + let store: RenderResourceStore = render_resource_store_make(2, 7, 4); + expect(state, render_resource_add(&store, render_resource_spec(0, + RenderResourceKind.path, "A", 1)) == RenderResourceError.invalid_id, + "nonpositive resource IDs fail visibly"); + expect(state, render_resource_add(&store, render_resource_spec(1, + RenderResourceKind.path, "", 1)) == RenderResourceError.invalid_payload, + "empty resource payloads cannot claim an asset"); + let late_revision: RenderResourceSpec = render_resource_spec(1, + RenderResourceKind.path, "A", 1); + late_revision.revision = 4; + expect(state, render_resource_add(&store, late_revision) == RenderResourceError.revision_conflict, + "new resource IDs must begin at canonical revision zero"); + let bad_image: RenderResourceSpec = render_resource_spec(1, + RenderResourceKind.image, "RGBA", 2); + expect(state, render_resource_add(&store, bad_image) == RenderResourceError.invalid_metadata, + "decoded images require dimensions and color-space truth"); + expect(state, render_resource_add(&store, render_resource_spec(1, + RenderResourceKind.path, "12345", 1)) == RenderResourceError.payload_limit, + "per-resource byte ceilings fail before copying payloads"); + _ = render_resource_add(&store, render_resource_spec(1, + RenderResourceKind.path, "1234", 1)); + expect(state, render_resource_add(&store, render_resource_spec(1, + RenderResourceKind.path, "12", 1)) == RenderResourceError.duplicate_id, + "duplicate stable resource IDs fail before ownership changes"); + expect(state, render_resource_add(&store, render_resource_spec(2, + RenderResourceKind.effect, "1234", 1)) == RenderResourceError.total_limit && + store.resources.len == 1 && store.total_bytes == 4, + "total byte ceilings are transactional and preserve prior resources"); + expect(state, render_resource_replace(&store, render_resource_spec(9, + RenderResourceKind.path, "A", 1)) == RenderResourceError.missing_resource, + "replacement of a missing ID fails without becoming insertion"); + expect(state, render_resource_store_verify(store) == RenderResourceError.not_sealed, + "unsealed stores cannot masquerade as immutable render evidence"); + render_resource_store_free(&store); + + let count_limited: RenderResourceStore = render_resource_store_make(1, 8, 4); + _ = render_resource_add(&count_limited, render_resource_spec(1, + RenderResourceKind.path, "A", 1)); + expect(state, render_resource_add(&count_limited, render_resource_spec(2, + RenderResourceKind.effect, "B", 1)) == RenderResourceError.too_many_resources, + "resource-count ceilings fail before allocating a second payload"); + render_resource_store_free(&count_limited); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + ownership_contract(&state); + canonical_contract(&state); + failure_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Render resources contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index e80e56f..82cb4f2 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -36,6 +36,8 @@ cd "$root" "$tmp/collection-semantics-contract" "$znc" tests/talkback_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/talkback-contract" "$tmp/talkback-contract" +"$znc" tests/render_resources_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/render-resources-contract" +"$tmp/render-resources-contract" "$znc" tests/display_list_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/display-list-contract" "$tmp/display-list-contract" "$znc" tests/display_list_codec_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/display-list-codec-contract" @@ -49,4 +51,4 @@ cd "$root" "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, display lists, CPU raster, input, replay, and motion)\n' +printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, display lists, CPU raster, input, replay, and motion)\n' From 4518e600cce7451602c0e42951eead7f10b8fb81 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 09:12:20 -0700 Subject: [PATCH 023/103] Integrate resources into display replay codec --- CHANGELOG.md | 6 +- README.md | 15 +-- docs/architecture/README.md | 18 ++-- docs/architecture/render-resources.md | 20 ++-- src/render/display_list.zag | 107 +++++++++++++++++- src/render/display_list_codec.zag | 137 +++++++++++++++++++++-- src/render/resources.zag | 10 +- src/replay/replay.zag | 10 ++ tests/cpu_raster_contract.zag | 2 + tests/display_list_codec_contract.zag | 150 ++++++++++++++++++++++++-- tests/display_list_contract.zag | 42 ++++++++ 11 files changed, 467 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27b60dd..ff3f0d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,11 +38,13 @@ include a generated capability record and exact Zag compiler revision. targeting, and explicit scale-aware pixel fallback. - A bounded owned render-resource store with typed stable IDs, canonical order, exact replacement revisions, copied payloads, byte accounting, sealing, - mutation detection, and explicit decoder and serialization boundaries. + mutation detection, typed display-list reference validation, and explicit + decoder boundaries. - An experimental immutable display list for explicit paints, resources, clips, transforms, layers, and effects with deterministic content identity. - A versioned canonical display-list codec with bounded validated decoding and - byte-identical replay round trips. + byte-identical replay round trips, upgraded to ZKDL version 2 with owned + resource metadata, payload, allocation-policy, and revision serialization. - The first deterministic CPU-oracle subset for fixed-point fills, clipping, axis-aligned transforms, fractional coverage, and source-over alpha. - Strict executable headless contracts in CI using the exact pinned Zag diff --git a/README.md b/README.md index 9d9e9ef..df7b2c0 100644 --- a/README.md +++ b/README.md @@ -133,14 +133,15 @@ fixed-point geometry, RGBA16 paints, paths, images, glyph runs, layers, and effects as explicit operations. Invalid geometry, resources, parameters, and stack balance fail before mutation; the builder rejects writes after sealing, and verification detects out-of-contract raw mutation against deterministic -content identity. A separate experimental resource store now owns opaque typed +content identity. An integrated experimental resource store owns opaque typed payload bytes with stable IDs, canonical order, bounded allocation, exact -replacement revisions, sealing, and mutation verification. It is not yet -attached to the display list, so payload schemas, resource serialization, -damage, complete CPU rasterization, and GPU transport remain open. The first versioned binary codec -now round-trips sealed lists byte-identically and rejects malformed, truncated, -noncanonical, unknown-version, and hash-mismatched input; schema evolution, -resource payloads, and fuzz coverage remain open. +replacement revisions, sealing, and mutation verification. Display-list seal +requires every reference to resolve the exact resource kind. The version 2 +binary codec round-trips resource metadata, payloads, revisions, allocation +policy, and operations byte-identically and rejects malformed, truncated, +noncanonical, unknown-version, and hash-mismatched input. Payload interpretation, +damage, complete CPU rasterization, GPU transport, schema evolution, and fuzz +coverage remain open. The first CPU-oracle subset rasterizes fixed-point rectangle fills with exact clip and axis-aligned transform state, area-based fractional edge coverage, and diff --git a/docs/architecture/README.md b/docs/architecture/README.md index e5a7aae..71e4e57 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -68,21 +68,23 @@ Rendering begins with an experimental immutable `DisplayList`. Every operation retains its owning `NodeKey`; paths, images, glyph runs, clips, transforms, RGBA16 paints, layers, and effects remain explicit rather than backend calls. Balanced lists seal with deterministic content identity, and verification -detects mutation outside the builder contract before rendering. Resource -storage, serialization, damage, the CPU oracle, and GPU transports remain open. +detects mutation outside the builder contract before rendering. Owned resource +payloads participate in sealing and identity; damage and GPU transports remain +open. -The first [render resource ownership layer](render-resources.md) copies opaque +The [render resource ownership layer](render-resources.md) copies opaque typed payloads behind positive stable IDs, canonicalizes insertion order, enforces configurable byte and count ceilings, requires exact replacement -revisions, and verifies sealed byte-level identity. It is not yet attached to -the display list and does not claim path, SVG, PNG, font, image, or glyph -decoding. +revisions, and verifies sealed byte-level identity. Display lists own the store +and validate each resource reference by kind; this still does not claim path, +SVG, PNG, font, image, or glyph decoding. -Display-list replay uses the versioned little-endian `ZKDL` codec. Decoding is +Display-list replay uses the versioned little-endian `ZKDL` version 2 codec. Decoding is bounded to one million operations, reconstructs operations only through the same validation path as live building, requires balanced seal state, verifies the stored content identity and revision, and rejects trailing bytes so one -scene has one canonical encoding. Resource payload serialization remains open. +scene has one canonical encoding. A bounded variable-length resource section +preserves allocation policy, typed metadata, revisions, and exact owned bytes. The CPU oracle begins with deterministic RGBA8 rectangle rasterization from 26.6 fixed-point geometry. Clip and positive axis-aligned transform state, diff --git a/docs/architecture/render-resources.md b/docs/architecture/render-resources.md index 176825a..5010b43 100644 --- a/docs/architecture/render-resources.md +++ b/docs/architecture/render-resources.md @@ -6,8 +6,7 @@ Status: experimental headless primitive will later feed Zagkit paths, decoded images, glyph runs, effects, fonts, SVG, and PNG. It is not a decoder and does not make an opaque payload renderable. SVG parsing, PNG parsing and color management, font validation, glyph shaping, -path schemas, and display-list payload serialization remain separate open -contracts. +path schemas, and decoded rendering remain separate open contracts. ## Identity and metadata @@ -45,8 +44,15 @@ revision changes, forged accounting, invalid limits, and aggregate identity changes. `render_resource_store_free` releases every owned payload and then the resource array; callers invoke it exactly once for every store, sealed or not. -The current executable contract does not attach this store to `DisplayList`, -serialize payloads in `ZKDL`, decode SVG or PNG, validate font data, rasterize -paths or images, cache platform uploads, or implement memory-pressure eviction. -Those capabilities remain unavailable until their own malformed-input, -round-trip, rendering, replacement, and cleanup suites pass. +`DisplayList` now owns this store. Sealing verifies that every resource-bearing +operation resolves an existing resource of the exact required kind. Resource +bytes, metadata, allocation policy, and replacement revisions contribute to +the display-list revision and content identity. `ZKDL` version 2 serializes the +canonical resource section before operations and reconstructs it through the +same bounded validation path; decoding and re-encoding are byte identical. + +The current executable contract still does not decode SVG or PNG, validate font +tables, interpret path or glyph payload schemas, rasterize paths or images, +cache platform uploads, or implement memory-pressure eviction. Those +capabilities remain unavailable until their own malformed-input, rendering, +replacement, and cleanup suites pass. diff --git a/src/render/display_list.zag b/src/render/display_list.zag index 5741d2f..a1f1d3c 100644 --- a/src/render/display_list.zag +++ b/src/render/display_list.zag @@ -1,6 +1,7 @@ @import("std:list") @import("../core/geometry.zag") @import("../core/view_contract.zag") +@import("resources.zag") enum DisplayOpKind { save, @@ -60,6 +61,7 @@ struct DisplayOp { struct DisplayList { ops: ArrayList[DisplayOp], + resources: RenderResourceStore, sealed: i32, stack_depth: i32, layer_depth: i32, @@ -129,20 +131,30 @@ fn display_op(kind: DisplayOpKind, owner: NodeKey) DisplayOp { }; } -fn display_list_make() DisplayList { +fn display_list_make_with_resource_limits(max_resources: i32, + max_total_bytes: i64, max_payload_bytes: i64) DisplayList { + let resources: RenderResourceStore = render_resource_store_make( + max_resources, max_total_bytes, max_payload_bytes + ); return DisplayList{ .ops = make[DisplayOp](32), + .resources = resources, .sealed = 0, .stack_depth = 0, .layer_depth = 0, .revision = 0, - .content_hash = 17, + .content_hash = display_mix(17, resources.content_hash), .last_error = DisplayError.none, .error_index = 0 - 1, }; } +fn display_list_make() DisplayList { + return display_list_make_with_resource_limits(4096, 268435456, 33554432); +} + fn display_list_free(list: *DisplayList) void { + render_resource_store_free(&list.*.resources); free[DisplayOp](&list.*.ops); } @@ -176,12 +188,79 @@ fn display_hash_op(hash: i64, op: DisplayOp) i64 { return display_mix(result, op.parameter); } +fn display_list_calculate_hash(list: DisplayList) i64 { + let hash: i64 = display_mix(17, list.resources.content_hash); + let i: i32 = 0; + while (i < list.ops.len) { + hash = display_hash_op(hash, list.ops.data[i]); + i = i + 1; + } + return hash; +} + +fn display_list_add_resource(list: *DisplayList, + spec: RenderResourceSpec) RenderResourceError { + if (list.*.sealed != 0) { return RenderResourceError.sealed; } + let error: RenderResourceError = render_resource_add(&list.*.resources, spec); + if (error == RenderResourceError.none) { + list.*.revision = list.*.ops.len as i64 + list.*.resources.revision; + list.*.content_hash = display_list_calculate_hash(list.*); + list.*.last_error = DisplayError.none; + list.*.error_index = 0 - 1; + } + return error; +} + +fn display_list_replace_resource(list: *DisplayList, + spec: RenderResourceSpec) RenderResourceError { + if (list.*.sealed != 0) { return RenderResourceError.sealed; } + let error: RenderResourceError = render_resource_replace(&list.*.resources, spec); + if (error == RenderResourceError.none) { + list.*.revision = list.*.ops.len as i64 + list.*.resources.revision; + list.*.content_hash = display_list_calculate_hash(list.*); + list.*.last_error = DisplayError.none; + list.*.error_index = 0 - 1; + } + return error; +} + fn display_fail(list: *DisplayList, error: DisplayError) DisplayError { list.*.last_error = error; list.*.error_index = list.*.ops.len; return error; } +fn display_fail_at(list: *DisplayList, error: DisplayError, index: i32) DisplayError { + list.*.last_error = error; + list.*.error_index = index; + return error; +} + +fn display_expected_resource_kind(kind: DisplayOpKind) i64 { + return switch (kind) { + .draw_path => RenderResourceKind.path as i64, + .draw_image => RenderResourceKind.image as i64, + .draw_glyph_run => RenderResourceKind.glyph_run as i64, + .apply_effect => RenderResourceKind.effect as i64, + .save => 0 - 1, + .restore => 0 - 1, + .clip_rect => 0 - 1, + .concat_transform => 0 - 1, + .fill_rect => 0 - 1, + .stroke_rect => 0 - 1, + .begin_layer => 0 - 1, + .end_layer => 0 - 1, + }; +} + +fn display_resource_reference_valid(list: DisplayList, op: DisplayOp) i32 { + let expected: i64 = display_expected_resource_kind(op.kind); + if (expected < 0) { return 1; } + let index: i32 = render_resource_find_index(list.resources, op.resource_id); + if (index < 0) { return 0; } + return (list.resources.resources.data[index].kind as i64 == expected) as i32; +} + fn display_op_requires_bounds(kind: DisplayOpKind) i32 { return (kind == DisplayOpKind.clip_rect || kind == DisplayOpKind.fill_rect || kind == DisplayOpKind.stroke_rect || kind == DisplayOpKind.draw_image || @@ -227,7 +306,7 @@ fn display_list_push(list: *DisplayList, op: DisplayOp) DisplayError { if (op.kind == DisplayOpKind.restore) { list.*.stack_depth = list.*.stack_depth - 1; } if (op.kind == DisplayOpKind.begin_layer) { list.*.layer_depth = list.*.layer_depth + 1; } if (op.kind == DisplayOpKind.end_layer) { list.*.layer_depth = list.*.layer_depth - 1; } - list.*.revision = list.*.revision + 1; + list.*.revision = list.*.ops.len as i64 + list.*.resources.revision; list.*.content_hash = display_hash_op(list.*.content_hash, op); list.*.last_error = DisplayError.none; list.*.error_index = 0 - 1; @@ -238,6 +317,18 @@ fn display_list_seal(list: *DisplayList) DisplayError { if (list.*.sealed != 0) { return DisplayError.none; } if (list.*.stack_depth != 0) { return display_fail(list, DisplayError.unbalanced_stack); } if (list.*.layer_depth != 0) { return display_fail(list, DisplayError.unbalanced_layers); } + let i: i32 = 0; + while (i < list.*.ops.len) { + if (display_resource_reference_valid(list.*, list.*.ops.data[i]) == 0) { + return display_fail_at(list, DisplayError.invalid_resource, i); + } + i = i + 1; + } + if (render_resource_store_seal(&list.*.resources) != RenderResourceError.none) { + return display_fail(list, DisplayError.invalid_resource); + } + list.*.content_hash = display_list_calculate_hash(list.*); + list.*.revision = list.*.ops.len as i64 + list.*.resources.revision; list.*.sealed = 1; list.*.last_error = DisplayError.none; list.*.error_index = 0 - 1; @@ -246,12 +337,18 @@ fn display_list_seal(list: *DisplayList) DisplayError { fn display_list_verify(list: DisplayList) DisplayError { if (list.sealed == 0) { return DisplayError.not_sealed; } - let hash: i64 = 17; + if (render_resource_store_verify(list.resources) != RenderResourceError.none) { + return DisplayError.content_mutated; + } + let hash: i64 = display_mix(17, list.resources.content_hash); let stack_depth: i32 = 0; let layer_depth: i32 = 0; let i: i32 = 0; while (i < list.ops.len) { let op: DisplayOp = list.ops.data[i]; + if (display_resource_reference_valid(list, op) == 0) { + return DisplayError.content_mutated; + } hash = display_hash_op(hash, op); if (op.kind == DisplayOpKind.save) { stack_depth = stack_depth + 1; } if (op.kind == DisplayOpKind.restore) { @@ -266,7 +363,7 @@ fn display_list_verify(list: DisplayList) DisplayError { i = i + 1; } if (stack_depth != 0 || layer_depth != 0 || hash != list.content_hash || - list.revision != list.ops.len as i64) { + list.revision != list.ops.len as i64 + list.resources.revision) { return DisplayError.content_mutated; } return DisplayError.none; diff --git a/src/render/display_list_codec.zag b/src/render/display_list_codec.zag index cb0d79d..0358920 100644 --- a/src/render/display_list_codec.zag +++ b/src/render/display_list_codec.zag @@ -12,6 +12,9 @@ enum DisplayCodecError { trailing_data, invalid_kind, invalid_operation, + invalid_resource, + invalid_payload, + invalid_configuration, hash_mismatch, } @@ -26,8 +29,9 @@ struct DisplayDecodeResult { error_offset: i64, } -fn display_codec_version() i64 { return 1; } -fn display_codec_header_size() i64 { return 36; } +fn display_codec_version() i64 { return 2; } +fn display_codec_header_size() i64 { return 76; } +fn display_codec_resource_header_size() i64 { return 64; } fn display_codec_op_size() i64 { return 160; } fn display_write_i64(bytes: *ArrayList[u8], value: i64) void { @@ -57,16 +61,59 @@ fn display_list_encode(list: DisplayList) DisplayEncodeResult { if (display_list_verify(list) != DisplayError.none) { return display_encode_fail(DisplayCodecError.content_mutated); } - if (list.ops.len < 0 || list.ops.len > 1000000) { + if (list.ops.len < 0 || list.ops.len > 1000000 || + list.resources.resources.len < 0 || + list.resources.resources.len > render_resource_hard_count_limit()) { return display_encode_fail(DisplayCodecError.invalid_count); } - let bytes: ArrayList[u8] = make[u8]((display_codec_header_size() + (list.ops.len as i64) * display_codec_op_size()) as i32); + let resource_bytes: i64 = 0; + let resource_index: i32 = 0; + while (resource_index < list.resources.resources.len) { + let resource: RenderResource = list.resources.resources.data[resource_index]; + if (resource.payload.len <= 0 || + resource.payload.len as i64 > list.resources.max_payload_bytes) { + return display_encode_fail(DisplayCodecError.invalid_payload); + } + resource_bytes = resource_bytes + display_codec_resource_header_size() + + resource.payload.len as i64; + resource_index = resource_index + 1; + } + let encoded_size: i64 = display_codec_header_size() + resource_bytes + + (list.ops.len as i64) * display_codec_op_size(); + if (encoded_size <= 0 || encoded_size > 1073741824) { + return display_encode_fail(DisplayCodecError.invalid_count); + } + let bytes: ArrayList[u8] = make[u8](encoded_size as i32); push[u8](&bytes, 90); push[u8](&bytes, 75); push[u8](&bytes, 68); push[u8](&bytes, 76); display_write_i64(&bytes, display_codec_version()); + display_write_i64(&bytes, list.resources.resources.len as i64); display_write_i64(&bytes, list.ops.len as i64); + display_write_i64(&bytes, resource_bytes); display_write_i64(&bytes, list.content_hash); display_write_i64(&bytes, list.revision); + display_write_i64(&bytes, list.resources.max_resources as i64); + display_write_i64(&bytes, list.resources.max_total_bytes); + display_write_i64(&bytes, list.resources.max_payload_bytes); + + let r: i32 = 0; + while (r < list.resources.resources.len) { + let resource: RenderResource = list.resources.resources.data[r]; + display_write_i64(&bytes, resource.id); + display_write_i64(&bytes, resource.kind as i64); + display_write_i64(&bytes, resource.revision); + display_write_i64(&bytes, resource.format_tag); + display_write_i64(&bytes, resource.width); + display_write_i64(&bytes, resource.height); + display_write_i64(&bytes, resource.color_space as i64); + display_write_i64(&bytes, resource.payload.len as i64); + let p: i32 = 0; + while (p < resource.payload.len) { + push[u8](&bytes, resource.payload.data[p]); + p = p + 1; + } + r = r + 1; + } let i: i32 = 0; while (i < list.ops.len) { @@ -116,21 +163,91 @@ fn display_list_decode(bytes: ArrayList[u8]) DisplayDecodeResult { if (version != display_codec_version()) { return display_decode_fail(list, DisplayCodecError.unsupported_version, 4); } - let count: i64 = display_read_i64(bytes, 12); - if (count < 0 || count > 1000000) { + let resource_count: i64 = display_read_i64(bytes, 12); + let count: i64 = display_read_i64(bytes, 20); + let resource_bytes: i64 = display_read_i64(bytes, 28); + if (resource_count < 0 || resource_count > render_resource_hard_count_limit() as i64) { return display_decode_fail(list, DisplayCodecError.invalid_count, 12); } - let expected_size: i64 = display_codec_header_size() + count * display_codec_op_size(); + if (count < 0 || count > 1000000 || resource_bytes < 0 || + resource_bytes > render_resource_hard_total_limit() + + resource_count * display_codec_resource_header_size()) { + return display_decode_fail(list, DisplayCodecError.invalid_count, 20); + } + let expected_size: i64 = display_codec_header_size() + resource_bytes + + count * display_codec_op_size(); if ((bytes.len as i64) < expected_size) { return display_decode_fail(list, DisplayCodecError.truncated, bytes.len as i64); } if ((bytes.len as i64) > expected_size) { return display_decode_fail(list, DisplayCodecError.trailing_data, expected_size); } - let stored_hash: i64 = display_read_i64(bytes, 20); - let stored_revision: i64 = display_read_i64(bytes, 28); + let stored_hash: i64 = display_read_i64(bytes, 36); + let stored_revision: i64 = display_read_i64(bytes, 44); + let max_resources: i64 = display_read_i64(bytes, 52); + let max_total_bytes: i64 = display_read_i64(bytes, 60); + let max_payload_bytes: i64 = display_read_i64(bytes, 68); + if (max_resources <= 0 || max_resources > render_resource_hard_count_limit() as i64 || + max_total_bytes <= 0 || max_total_bytes > render_resource_hard_total_limit() || + max_payload_bytes <= 0 || max_payload_bytes > render_resource_hard_payload_limit() || + max_payload_bytes > max_total_bytes || resource_count > max_resources) { + return display_decode_fail(list, DisplayCodecError.invalid_configuration, 52); + } + display_list_free(&list); + list = display_list_make_with_resource_limits( + max_resources as i32, max_total_bytes, max_payload_bytes + ); let offset: i64 = display_codec_header_size(); + let resources_end: i64 = display_codec_header_size() + resource_bytes; + let r: i64 = 0; + while (r < resource_count) { + if (offset + display_codec_resource_header_size() > resources_end) { + return display_decode_fail(list, DisplayCodecError.truncated, offset); + } + let kind_code: i64 = display_read_i64(bytes, offset + 8); + let color_code: i64 = display_read_i64(bytes, offset + 48); + let payload_length: i64 = display_read_i64(bytes, offset + 56); + if (kind_code < 0 || kind_code > 6 || color_code < 0 || color_code > 3) { + return display_decode_fail(list, DisplayCodecError.invalid_resource, offset); + } + if (payload_length <= 0 || payload_length > max_payload_bytes || + offset + display_codec_resource_header_size() + payload_length > resources_end) { + return display_decode_fail(list, DisplayCodecError.invalid_payload, offset + 56); + } + let payload_start: i32 = (offset + display_codec_resource_header_size()) as i32; + let payload_end: i32 = (offset + display_codec_resource_header_size() + payload_length) as i32; + let spec: RenderResourceSpec = render_resource_spec( + display_read_i64(bytes, offset), kind_code as RenderResourceKind, + bytes.data[payload_start..payload_end], display_read_i64(bytes, offset + 24) + ); + let stored_resource_revision: i64 = display_read_i64(bytes, offset + 16); + spec.width = display_read_i64(bytes, offset + 32); + spec.height = display_read_i64(bytes, offset + 40); + spec.color_space = color_code as RenderColorSpace; + if (stored_resource_revision < 0 || stored_resource_revision > 2147483647 || + render_resource_add(&list.resources, spec) != RenderResourceError.none) { + return display_decode_fail(list, DisplayCodecError.invalid_resource, offset); + } + let added_index: i32 = render_resource_find_index(list.resources, spec.id); + list.resources.resources.data[added_index].revision = stored_resource_revision; + list.resources.resources.data[added_index].content_hash = + render_resource_hash(list.resources.resources.data[added_index]); + offset = offset + display_codec_resource_header_size() + payload_length; + r = r + 1; + } + if (offset != resources_end) { + return display_decode_fail(list, DisplayCodecError.invalid_payload, offset); + } + if (stored_revision < count) { + return display_decode_fail(list, DisplayCodecError.hash_mismatch, 44); + } + let resource_revision: i64 = stored_revision - count; + list.resources.revision = resource_revision; + render_resource_store_rehash(&list.resources); + list.revision = resource_revision; + list.content_hash = display_list_calculate_hash(list); + let i: i64 = 0; while (i < count) { let kind_code: i64 = display_read_i64(bytes, offset); @@ -174,7 +291,7 @@ fn display_list_decode(bytes: ArrayList[u8]) DisplayDecodeResult { return display_decode_fail(list, DisplayCodecError.invalid_operation, offset); } if (list.content_hash != stored_hash || list.revision != stored_revision) { - return display_decode_fail(list, DisplayCodecError.hash_mismatch, 20); + return display_decode_fail(list, DisplayCodecError.hash_mismatch, 36); } return DisplayDecodeResult{ .list = list, .error = DisplayCodecError.none, .error_offset = 0 - 1 }; } diff --git a/src/render/resources.zag b/src/render/resources.zag index c056702..cdb64ab 100644 --- a/src/render/resources.zag +++ b/src/render/resources.zag @@ -169,7 +169,8 @@ fn render_resource_fail(store: *RenderResourceStore, fn render_resource_spec_valid(spec: RenderResourceSpec) RenderResourceError { if (spec.id <= 0) { return RenderResourceError.invalid_id; } if (spec.payload.len <= 0) { return RenderResourceError.invalid_payload; } - if (spec.revision < 0 || spec.format_tag <= 0 || spec.width < 0 || + if (spec.revision < 0 || spec.revision > 2147483647 || + spec.format_tag <= 0 || spec.width < 0 || spec.height < 0 || spec.width > 1048576 || spec.height > 1048576) { return RenderResourceError.invalid_metadata; } @@ -327,6 +328,7 @@ fn render_resource_store_verify(store: RenderResourceStore) RenderResourceError if (render_resource_store_configuration_valid(store) == 0 || store.resources.len > store.max_resources) { return RenderResourceError.content_mutated; } let total: i64 = 0; + let expected_revision: i64 = 0; let i: i32 = 0; while (i < store.resources.len) { let resource: RenderResource = store.resources.data[i]; @@ -337,10 +339,14 @@ fn render_resource_store_verify(store: RenderResourceStore) RenderResourceError return RenderResourceError.content_mutated; } total = total + resource.payload.len as i64; + if (resource.revision < 0 || resource.revision > 2147483647) { + return RenderResourceError.content_mutated; + } + expected_revision = expected_revision + resource.revision + 1; if (total > store.max_total_bytes) { return RenderResourceError.content_mutated; } i = i + 1; } - if (total != store.total_bytes || store.revision < store.resources.len as i64 || + if (total != store.total_bytes || store.revision != expected_revision || render_resource_store_hash(store) != store.content_hash) { return RenderResourceError.content_mutated; } diff --git a/src/replay/replay.zag b/src/replay/replay.zag index e23dab7..c117928 100644 --- a/src/replay/replay.zag +++ b/src/replay/replay.zag @@ -604,6 +604,16 @@ fn replay_build_scene(state_value: i64, backend_generation: i64) ReplayResult { overlay_free(&overlay_layout_result); let list: DisplayList = display_list_make(); + let replay_payload: []u8 = "REPLAY-RESOURCE-0"; + if (magnitude != 0) { replay_payload = "REPLAY-RESOURCE-1"; } + if (display_list_add_resource(&list, render_resource_spec( + 900, RenderResourceKind.effect, replay_payload, 1 + )) != RenderResourceError.none) { + display_list_free(&list); + flex_wrap_free(&layout); + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } let first_op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(101)); first_op.bounds = layout.placements.data[0].frame; let blue: i64 = (backend_generation % 8) * 4096; diff --git a/tests/cpu_raster_contract.zag b/tests/cpu_raster_contract.zag index 928ea82..571534b 100644 --- a/tests/cpu_raster_contract.zag +++ b/tests/cpu_raster_contract.zag @@ -101,6 +101,8 @@ fn failure_contract(state: *TestState) void { display_list_free(&unsealed); let unsupported: DisplayList = display_list_make(); + _ = display_list_add_resource(&unsupported, + render_resource_spec(8, RenderResourceKind.path, "PATH", 1)); let path: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(1)); path.resource_id = 8; _ = display_list_push(&unsupported, path); diff --git a/tests/display_list_codec_contract.zag b/tests/display_list_codec_contract.zag index 15f8a76..294c1b7 100644 --- a/tests/display_list_codec_contract.zag +++ b/tests/display_list_codec_contract.zag @@ -15,6 +15,13 @@ fn expect(state: *TestState, condition: i32, name: []u8) void { fn codec_scene() DisplayList { let list: DisplayList = display_list_make(); let owner: NodeKey = node_key_scoped(42, 3); + _ = display_list_add_resource(&list, + render_resource_spec(50, RenderResourceKind.path, "M0L1", 7)); + let replacement: RenderResourceSpec = render_resource_spec( + 50, RenderResourceKind.path, "M0L2", 7 + ); + replacement.revision = 1; + _ = display_list_replace_resource(&list, replacement); _ = display_list_push(&list, display_op(DisplayOpKind.save, owner)); let clip: DisplayOp = display_op(DisplayOpKind.clip_rect, owner); clip.bounds = rect(0 - unit_scale(), 2 * unit_scale(), 80 * unit_scale(), 40 * unit_scale()); @@ -23,6 +30,9 @@ fn codec_scene() DisplayList { fill.bounds = rect(3 * unit_scale(), 4 * unit_scale(), 20 * unit_scale(), 10 * unit_scale()); fill.paint = paint_rgba16(1234, 2345, 3456, 4567); _ = display_list_push(&list, fill); + let path: DisplayOp = display_op(DisplayOpKind.draw_path, owner); + path.resource_id = 50; + _ = display_list_push(&list, path); _ = display_list_push(&list, display_op(DisplayOpKind.restore, owner)); _ = display_list_seal(&list); return list; @@ -41,7 +51,9 @@ fn bytes_equal(a: ArrayList[u8], b: ArrayList[u8]) i32 { fn round_trip_contract(state: *TestState) void { let source: DisplayList = codec_scene(); let encoded: DisplayEncodeResult = display_list_encode(source); - expect(state, encoded.error == DisplayCodecError.none && encoded.bytes.len == 36 + 4 * 160, "sealed display list encodes to the versioned exact size"); + expect(state, encoded.error == DisplayCodecError.none && encoded.bytes.len == + 76 + 64 + 4 + 5 * 160, + "sealed display list encodes resource records payload bytes and operations to the exact size"); expect(state, encoded.bytes.data[0] == 90 && encoded.bytes.data[1] == 75 && encoded.bytes.data[2] == 68 && encoded.bytes.data[3] == 76, "codec carries the ZKDL magic"); let decoded: DisplayDecodeResult = display_list_decode(encoded.bytes); @@ -49,6 +61,13 @@ fn round_trip_contract(state: *TestState) void { expect(state, decoded.list.content_hash == source.content_hash && decoded.list.revision == source.revision, "round trip preserves deterministic identity"); expect(state, decoded.list.ops.data[2].owner.value == 42 && decoded.list.ops.data[2].owner.generation == 3, "round trip preserves retained ownership"); expect(state, decoded.list.ops.data[1].bounds.x == 0 - unit_scale() && decoded.list.ops.data[2].paint.alpha == 4567, "round trip preserves signed geometry and RGBA16 paint"); + expect(state, decoded.list.resources.resources.len == 1 && + decoded.list.resources.resources.data[0].id == 50 && + decoded.list.resources.resources.data[0].revision == 1 && + decoded.list.resources.resources.data[0].payload.len == 4 && + decoded.list.resources.resources.data[0].payload.data[3] == 50 && + decoded.list.resources.revision == 2, + "round trip preserves replacement revision metadata and exact owned payload bytes"); let encoded_again: DisplayEncodeResult = display_list_encode(decoded.list); expect(state, bytes_equal(encoded.bytes, encoded_again.bytes) == 1, "decode and re-encode are byte identical"); @@ -70,25 +89,98 @@ fn malformed_contract(state: *TestState) void { encoded.bytes.data[0] = saved; let saved_version: u8 = encoded.bytes.data[4]; - encoded.bytes.data[4] = 2; + encoded.bytes.data[4] = 3; let bad_version: DisplayDecodeResult = display_list_decode(encoded.bytes); expect(state, bad_version.error == DisplayCodecError.unsupported_version && bad_version.error_offset == 4, "unsupported versions fail visibly"); display_decode_free(&bad_version); encoded.bytes.data[4] = saved_version; - let saved_kind: u8 = encoded.bytes.data[36]; - encoded.bytes.data[36] = 99; + let operation_offset: i32 = 76 + 64 + 4; + let saved_kind: u8 = encoded.bytes.data[operation_offset]; + encoded.bytes.data[operation_offset] = 99; let bad_kind: DisplayDecodeResult = display_list_decode(encoded.bytes); - expect(state, bad_kind.error == DisplayCodecError.invalid_kind && bad_kind.error_offset == 36, "unknown operation kinds fail at the exact record"); + expect(state, bad_kind.error == DisplayCodecError.invalid_kind && + bad_kind.error_offset == operation_offset as i64, + "unknown operation kinds fail at the exact record"); display_decode_free(&bad_kind); - encoded.bytes.data[36] = saved_kind; + encoded.bytes.data[operation_offset] = saved_kind; - let saved_paint: u8 = encoded.bytes.data[36 + 2 * 160 + 56]; - encoded.bytes.data[36 + 2 * 160 + 56] = saved_paint + 1; + let paint_offset: i32 = operation_offset + 2 * 160 + 56; + let saved_paint: u8 = encoded.bytes.data[paint_offset]; + encoded.bytes.data[paint_offset] = saved_paint + 1; let bad_hash: DisplayDecodeResult = display_list_decode(encoded.bytes); expect(state, bad_hash.error == DisplayCodecError.hash_mismatch, "content tampering fails deterministic identity verification"); display_decode_free(&bad_hash); - encoded.bytes.data[36 + 2 * 160 + 56] = saved_paint; + encoded.bytes.data[paint_offset] = saved_paint; + + let saved_resource_kind: u8 = encoded.bytes.data[76 + 8]; + encoded.bytes.data[76 + 8] = 99; + let bad_resource: DisplayDecodeResult = display_list_decode(encoded.bytes); + expect(state, bad_resource.error == DisplayCodecError.invalid_resource && + bad_resource.error_offset == 76, + "unknown resource kinds fail at the exact resource record"); + display_decode_free(&bad_resource); + encoded.bytes.data[76 + 8] = saved_resource_kind; + + let saved_valid_kind: u8 = encoded.bytes.data[76 + 8]; + encoded.bytes.data[76 + 8] = RenderResourceKind.effect as u8; + let mismatched_resource: DisplayDecodeResult = display_list_decode(encoded.bytes); + expect(state, mismatched_resource.error == DisplayCodecError.invalid_operation, + "decoded resource kind mismatch fails before the referenced operation can seal"); + display_decode_free(&mismatched_resource); + encoded.bytes.data[76 + 8] = saved_valid_kind; + + let saved_format: u8 = encoded.bytes.data[76 + 24]; + encoded.bytes.data[76 + 24] = 0; + let bad_format: DisplayDecodeResult = display_list_decode(encoded.bytes); + expect(state, bad_format.error == DisplayCodecError.invalid_resource && + bad_format.error_offset == 76, + "invalid resource metadata fails at its record before payload ownership"); + display_decode_free(&bad_format); + encoded.bytes.data[76 + 24] = saved_format; + + let saved_length: u8 = encoded.bytes.data[76 + 56]; + encoded.bytes.data[76 + 56] = 100; + let payload_bomb: DisplayDecodeResult = display_list_decode(encoded.bytes); + expect(state, payload_bomb.error == DisplayCodecError.invalid_payload && + payload_bomb.error_offset == 76 + 56, + "payload lengths cannot cross the declared bounded resource section"); + display_decode_free(&payload_bomb); + encoded.bytes.data[76 + 56] = saved_length; + + let saved_configuration: u8 = encoded.bytes.data[53]; + encoded.bytes.data[53] = 0; + let bad_configuration: DisplayDecodeResult = display_list_decode(encoded.bytes); + expect(state, bad_configuration.error == DisplayCodecError.invalid_configuration && + bad_configuration.error_offset == 52, + "serialized allocation policy must remain within hard decoder limits"); + display_decode_free(&bad_configuration); + encoded.bytes.data[53] = saved_configuration; + + let saved_revision: u8 = encoded.bytes.data[44]; + encoded.bytes.data[44] = saved_revision - 1; + let bad_revision: DisplayDecodeResult = display_list_decode(encoded.bytes); + expect(state, bad_revision.error == DisplayCodecError.invalid_operation, + "resource history revisions must reconstruct exactly before sealing"); + display_decode_free(&bad_revision); + encoded.bytes.data[44] = saved_revision; + + let reference_offset: i32 = operation_offset + 3 * 160 + 144; + let saved_reference: u8 = encoded.bytes.data[reference_offset]; + encoded.bytes.data[reference_offset] = 99; + let missing_reference: DisplayDecodeResult = display_list_decode(encoded.bytes); + expect(state, missing_reference.error == DisplayCodecError.invalid_operation, + "decoded operations cannot reference absent resource IDs"); + display_decode_free(&missing_reference); + encoded.bytes.data[reference_offset] = saved_reference; + + let saved_payload: u8 = encoded.bytes.data[76 + 64]; + encoded.bytes.data[76 + 64] = saved_payload + 1; + let bad_payload_hash: DisplayDecodeResult = display_list_decode(encoded.bytes); + expect(state, bad_payload_hash.error == DisplayCodecError.hash_mismatch, + "resource payload tampering fails aggregate display identity"); + display_decode_free(&bad_payload_hash); + encoded.bytes.data[76 + 64] = saved_payload; let truncated: ArrayList[u8] = make[u8](20); let i: i32 = 0; @@ -115,11 +207,51 @@ fn encode_rejection_contract(state: *TestState) void { display_list_free(&unsealed); } +fn resource_payload_sweep(state: *TestState) void { + let stable: i32 = 1; + let payload_length: i32 = 1; + while (payload_length <= 64) { + let payload: ArrayList[u8] = make[u8](payload_length); + let i: i32 = 0; + while (i < payload_length) { + push[u8](&payload, ((i * 17 + payload_length) % 251) as u8); + i = i + 1; + } + let list: DisplayList = display_list_make_with_resource_limits(4, 256, 128); + if (display_list_add_resource(&list, render_resource_spec( + 70, RenderResourceKind.path, payload.data[0..payload.len], 9 + )) != RenderResourceError.none) { stable = 0; } + let path: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(70)); + path.resource_id = 70; + if (display_list_push(&list, path) != DisplayError.none || + display_list_seal(&list) != DisplayError.none) { stable = 0; } + let encoded: DisplayEncodeResult = display_list_encode(list); + let decoded: DisplayDecodeResult = display_list_decode(encoded.bytes); + if (encoded.error != DisplayCodecError.none || + decoded.error != DisplayCodecError.none || + decoded.list.resources.resources.len != 1 || + decoded.list.resources.resources.data[0].payload.len != payload_length || + decoded.list.content_hash != list.content_hash) { stable = 0; } + let encoded_again: DisplayEncodeResult = display_list_encode(decoded.list); + if (encoded_again.error != DisplayCodecError.none || + bytes_equal(encoded.bytes, encoded_again.bytes) == 0) { stable = 0; } + display_encode_free(&encoded_again); + display_decode_free(&decoded); + display_encode_free(&encoded); + display_list_free(&list); + free[u8](&payload); + payload_length = payload_length + 1; + } + expect(state, stable, + "64 payload lengths preserve canonical resource bytes through decode and re-encode"); +} + fn main() i32 { let state: TestState = TestState{ .passed = 0, .failed = 0 }; round_trip_contract(&state); malformed_contract(&state); encode_rejection_contract(&state); + resource_payload_sweep(&state); let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); _zag_print("Display-list codec contract: pass="); _zag_print(passed_text); diff --git a/tests/display_list_contract.zag b/tests/display_list_contract.zag index 386a100..b09cd1e 100644 --- a/tests/display_list_contract.zag +++ b/tests/display_list_contract.zag @@ -15,6 +15,18 @@ fn expect(state: *TestState, condition: i32, name: []u8) void { fn build_scene(color: i64) DisplayList { let list: DisplayList = display_list_make(); let owner: NodeKey = node_key(7); + _ = display_list_add_resource(&list, + render_resource_spec(101, RenderResourceKind.path, "PATH", 1)); + let image_resource: RenderResourceSpec = render_resource_spec( + 202, RenderResourceKind.image, "RGBA", 2 + ); + image_resource.width = 1; image_resource.height = 1; + image_resource.color_space = RenderColorSpace.srgb; + _ = display_list_add_resource(&list, image_resource); + _ = display_list_add_resource(&list, + render_resource_spec(303, RenderResourceKind.glyph_run, "GLYPHS", 3)); + _ = display_list_add_resource(&list, + render_resource_spec(404, RenderResourceKind.effect, "EFFECT", 4)); _ = display_list_push(&list, display_op(DisplayOpKind.save, owner)); let clip: DisplayOp = display_op(DisplayOpKind.clip_rect, owner); @@ -64,6 +76,8 @@ fn scene_contract(state: *TestState) void { let second: DisplayList = build_scene(10000); let changed: DisplayList = build_scene(10001); expect(state, first.ops.len == 11 && first.stack_depth == 0 && first.layer_depth == 0, "display list records a balanced scene"); + expect(state, first.resources.resources.len == 4 && first.revision == 15, + "display list owns every referenced resource and includes resource revisions"); expect(state, first.ops.data[4].kind == DisplayOpKind.draw_path && first.ops.data[5].kind == DisplayOpKind.draw_image && first.ops.data[6].kind == DisplayOpKind.draw_glyph_run, "paths, images, and glyph runs remain explicit operations"); expect(state, first.ops.data[7].kind == DisplayOpKind.begin_layer && first.ops.data[8].kind == DisplayOpKind.apply_effect, "layers and effects remain explicit operations"); expect(state, first.content_hash == second.content_hash, "identical scenes produce the same deterministic content hash"); @@ -79,6 +93,14 @@ fn scene_contract(state: *TestState) void { expect(state, display_list_verify(first) == DisplayError.content_mutated, "out-of-contract raw mutation is detected before rendering"); first.ops.data[3].paint.red = first.ops.data[3].paint.red - 1; expect(state, display_list_verify(first) == DisplayError.none, "restored sealed content verifies again"); + first.resources.resources.data[0].payload.data[0] = + first.resources.resources.data[0].payload.data[0] + 1; + expect(state, display_list_verify(first) == DisplayError.content_mutated, + "display verification detects owned resource payload mutation"); + first.resources.resources.data[0].payload.data[0] = + first.resources.resources.data[0].payload.data[0] - 1; + expect(state, display_list_verify(first) == DisplayError.none, + "restored resource bytes restore complete display identity"); display_list_free(&changed); display_list_free(&second); @@ -114,6 +136,26 @@ fn rejection_contract(state: *TestState) void { _ = display_list_push(&list, display_op(DisplayOpKind.restore, node_key(1))); expect(state, display_list_seal(&list) == DisplayError.none && list.sealed == 1, "a corrected stack seals without rebuilding the list"); display_list_free(&list); + + let missing: DisplayList = display_list_make(); + let missing_path: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(2)); + missing_path.resource_id = 99; + _ = display_list_push(&missing, missing_path); + expect(state, display_list_seal(&missing) == DisplayError.invalid_resource && + missing.error_index == 0 && missing.sealed == 0, + "sealing fails at the exact missing resource reference"); + display_list_free(&missing); + + let mismatched: DisplayList = display_list_make(); + _ = display_list_add_resource(&mismatched, + render_resource_spec(5, RenderResourceKind.effect, "FX", 1)); + let wrong_path: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(3)); + wrong_path.resource_id = 5; + _ = display_list_push(&mismatched, wrong_path); + expect(state, display_list_seal(&mismatched) == DisplayError.invalid_resource && + mismatched.error_index == 0, + "resource kind mismatches cannot masquerade as drawable payloads"); + display_list_free(&mismatched); } fn deterministic_sweep(state: *TestState) void { From 199d14bdc121ef54388ba2844cf0d919ebddc661 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 09:20:35 -0700 Subject: [PATCH 024/103] Add canonical vector path resources --- CHANGELOG.md | 4 + README.md | 15 +- docs/architecture/README.md | 16 +- docs/architecture/paths.md | 42 +++ docs/architecture/render-resources.md | 11 +- src/render/display_list.zag | 32 ++- src/render/display_list_codec.zag | 5 + src/render/path.zag | 396 ++++++++++++++++++++++++++ tests/cpu_raster_contract.zag | 17 +- tests/display_list_codec_contract.zag | 49 ++-- tests/display_list_contract.zag | 28 +- tests/path_contract.zag | 197 +++++++++++++ tools/test-headless.sh | 4 +- 13 files changed, 779 insertions(+), 37 deletions(-) create mode 100644 docs/architecture/paths.md create mode 100644 src/render/path.zag create mode 100644 tests/path_contract.zag diff --git a/CHANGELOG.md b/CHANGELOG.md index ff3f0d4..4d1bc08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,10 @@ include a generated capability record and exact Zag compiler revision. exact replacement revisions, copied payloads, byte accounting, sealing, mutation detection, typed display-list reference validation, and explicit decoder boundaries. +- A canonical bounded `ZKPATH01` vector-path builder and codec with non-zero or + even-odd fill rules, line and curve commands, immutable identity, typed + display-resource validation, malformed-input offsets, and byte-identical + round trips. - An experimental immutable display list for explicit paints, resources, clips, transforms, layers, and effects with deterministic content identity. - A versioned canonical display-list codec with bounded validated decoding and diff --git a/README.md b/README.md index df7b2c0..e5b8982 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ is normative in [DEPENDENCIES.md](DEPENDENCIES.md). | Product and architecture contract | accepted | [RFC index](docs/rfcs/README.md) | | Compiler dependency | pinned, prerequisites incomplete | [toolchain lock](contracts/toolchain.json) | | Platform shells | unavailable | [support matrix](SUPPORT.md) | -| Headless core | experimental state, reconciliation, intrinsic measurement, geometry, Flex, Grid, Overlay, scroll, virtual collections, collection semantics, Talkback, display lists, CPU raster, input, replay, and motion | [headless test](tools/test-headless.sh) | +| Headless core | experimental state, reconciliation, intrinsic measurement, geometry, Flex, Grid, Overlay, scroll, virtual collections, collection semantics, Talkback, canonical paths, display lists, CPU raster, input, replay, and motion | [headless test](tools/test-headless.sh) | | Components and visual language | inventory only, visual review pending | [component inventory](contracts/components.json) | | Flex and Zagkit Talkback | Flex foundation and in-process ID-first Talkback dispatch executing; native transport remains unavailable | [Talkback contract](docs/automation/talkback.md) | | Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | @@ -136,12 +136,17 @@ and verification detects out-of-contract raw mutation against deterministic content identity. An integrated experimental resource store owns opaque typed payload bytes with stable IDs, canonical order, bounded allocation, exact replacement revisions, sealing, and mutation verification. Display-list seal -requires every reference to resolve the exact resource kind. The version 2 +requires every reference to resolve the exact resource kind. Path resources use +a bounded immutable command builder and canonical `ZKPATH01` version 1 payload; +fill rules, moves, lines, quadratic and cubic curves, contour closure, sequence, +geometry, identity, truncation, and trailing bytes are validated before use. +The version 2 binary codec round-trips resource metadata, payloads, revisions, allocation policy, and operations byte-identically and rejects malformed, truncated, -noncanonical, unknown-version, and hash-mismatched input. Payload interpretation, -damage, complete CPU rasterization, GPU transport, schema evolution, and fuzz -coverage remain open. +noncanonical, unknown-version, and hash-mismatched input. Path flattening and +rasterization, other payload interpretation, damage, complete CPU rasterization, +GPU transport, schema evolution, and fuzz coverage remain open. See the +[path contract](docs/architecture/paths.md). The first CPU-oracle subset rasterizes fixed-point rectangle fills with exact clip and axis-aligned transform state, area-based fractional edge coverage, and diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 71e4e57..0228d05 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -31,6 +31,7 @@ Detailed experimental contracts: - [intrinsic measurement and invalidation reasons](measurement.md) - [Flex placement and adaptive spacing](flex.md) - [motion scheduler and tracks](motion.md) +- [canonical vector paths](paths.md) The first experimental compiled slice now fixes the initial Zag shapes for `NodeKey`, `State`, `Binding`, `Action`, `Environment`, `ViewContext`, @@ -72,15 +73,16 @@ detects mutation outside the builder contract before rendering. Owned resource payloads participate in sealing and identity; damage and GPU transports remain open. -The [render resource ownership layer](render-resources.md) copies opaque -typed payloads behind positive stable IDs, canonicalizes insertion order, +The [render resource ownership layer](render-resources.md) copies typed payloads +behind positive stable IDs, canonicalizes insertion order, enforces configurable byte and count ceilings, requires exact replacement -revisions, and verifies sealed byte-level identity. Display lists own the store -and validate each resource reference by kind; this still does not claim path, -SVG, PNG, font, image, or glyph decoding. +revisions, and verifies sealed byte-level identity. Display lists own the store. +Path resources additionally require the bounded canonical +[ZKPATH01 contract](paths.md) and are validated once before operation references +seal. SVG, PNG, font, image, and glyph decoding remain unavailable. -Display-list replay uses the versioned little-endian `ZKDL` version 2 codec. Decoding is -bounded to one million operations, reconstructs operations only through the +Display-list replay uses the versioned little-endian `ZKDL` version 2 codec. +Decoding is bounded to one million operations, reconstructs operations through the same validation path as live building, requires balanced seal state, verifies the stored content identity and revision, and rejects trailing bytes so one scene has one canonical encoding. A bounded variable-length resource section diff --git a/docs/architecture/paths.md b/docs/architecture/paths.md new file mode 100644 index 0000000..8441076 --- /dev/null +++ b/docs/architecture/paths.md @@ -0,0 +1,42 @@ +# Canonical vector paths + +Status: experimental headless primitive + +Zagkit paths use a Zag-owned immutable command stream rather than backend path +objects. `PathData` records one explicit fill rule and ordered `move_to`, +`line_to`, `quad_to`, `cubic_to`, and `close` commands in the same signed 26.6 +fixed-point coordinate domain as layout and display lists. + +## Builder and sequence rules + +Every contour starts with `move_to`. Line and curve commands require an open +contour; `close` closes exactly one open contour, and more geometry then +requires another move. A new move may begin a new contour without an explicit +close because open contours are legal path input. Empty paths, commands before +a move, duplicate closes, noncanonical unused point fields, out-of-domain +coordinates, and more than 65,536 commands fail before builder mutation. + +Sealing computes deterministic identity over the fill rule and every command +field. Sealed builders reject writes, while verification detects direct command +or identity mutation. Callers own each `PathData` and release it with +`path_free`. + +## ZKPATH01 bytes + +`ZKPATH01` version 1 is the canonical little-endian resource payload. Its +40-byte header contains magic, version, fill rule, command count, and content +hash. Each command occupies 56 bytes: verb plus three fixed-point points. +Unused points must be zero, so one path has one encoding. Decoding is bounded, +reuses the live builder validation path, produces a sealed path, and rejects +bad magic, versions, fill rules, verbs, sequences, geometry, hashes, +truncation, and trailing bytes with an exact offset. + +Path render resources use format tag 1. Display-list sealing validates every +owned path payload once, then validates operation references by stable ID, +resource kind, and format. This avoids both opaque-path acceptance and repeated +decode work when one path is drawn many times. + +This contract does not yet flatten curves, compute analytic curve extrema, +rasterize fills or strokes, apply dashes, perform boolean path operations, or +decode SVG path syntax. Those remain unavailable until their deterministic, +malformed-input, golden, and cleanup suites pass. diff --git a/docs/architecture/render-resources.md b/docs/architecture/render-resources.md index 5010b43..c66a234 100644 --- a/docs/architecture/render-resources.md +++ b/docs/architecture/render-resources.md @@ -3,10 +3,11 @@ Status: experimental headless primitive `RenderResourceStore` is the ownership and identity boundary for bytes that -will later feed Zagkit paths, decoded images, glyph runs, effects, fonts, SVG, -and PNG. It is not a decoder and does not make an opaque payload renderable. +feed Zagkit paths, decoded images, glyph runs, effects, fonts, SVG, and PNG. It +is not itself a decoder and does not make an opaque payload renderable. SVG parsing, PNG parsing and color management, font validation, glyph shaping, -path schemas, and decoded rendering remain separate open contracts. +and decoded rendering remain separate open contracts. Canonical path payloads +are the first typed schema layered over this store; see [paths](paths.md). ## Identity and metadata @@ -52,7 +53,7 @@ canonical resource section before operations and reconstructs it through the same bounded validation path; decoding and re-encoding are byte identical. The current executable contract still does not decode SVG or PNG, validate font -tables, interpret path or glyph payload schemas, rasterize paths or images, -cache platform uploads, or implement memory-pressure eviction. Those +tables, interpret glyph payload schemas, rasterize paths or images, cache +platform uploads, or implement memory-pressure eviction. Those capabilities remain unavailable until their own malformed-input, rendering, replacement, and cleanup suites pass. diff --git a/src/render/display_list.zag b/src/render/display_list.zag index a1f1d3c..0be52f4 100644 --- a/src/render/display_list.zag +++ b/src/render/display_list.zag @@ -2,6 +2,7 @@ @import("../core/geometry.zag") @import("../core/view_contract.zag") @import("resources.zag") +@import("path.zag") enum DisplayOpKind { save, @@ -258,7 +259,20 @@ fn display_resource_reference_valid(list: DisplayList, op: DisplayOp) i32 { if (expected < 0) { return 1; } let index: i32 = render_resource_find_index(list.resources, op.resource_id); if (index < 0) { return 0; } - return (list.resources.resources.data[index].kind as i64 == expected) as i32; + let resource: RenderResource = list.resources.resources.data[index]; + if ((resource.kind as i64) != expected) { return 0; } + if (op.kind == DisplayOpKind.draw_path) { + return (resource.format_tag == path_resource_format_tag()) as i32; + } + return 1; +} + +fn display_resource_payload_valid(resource: RenderResource) i32 { + if (resource.kind == RenderResourceKind.path) { + return (resource.format_tag == path_resource_format_tag() && + path_payload_valid(resource.payload.data[0..resource.payload.len]) != 0) as i32; + } + return 1; } fn display_op_requires_bounds(kind: DisplayOpKind) i32 { @@ -317,6 +331,14 @@ fn display_list_seal(list: *DisplayList) DisplayError { if (list.*.sealed != 0) { return DisplayError.none; } if (list.*.stack_depth != 0) { return display_fail(list, DisplayError.unbalanced_stack); } if (list.*.layer_depth != 0) { return display_fail(list, DisplayError.unbalanced_layers); } + let resource_index: i32 = 0; + while (resource_index < list.*.resources.resources.len) { + if (display_resource_payload_valid( + list.*.resources.resources.data[resource_index]) == 0) { + return display_fail_at(list, DisplayError.invalid_resource, 0 - 1); + } + resource_index = resource_index + 1; + } let i: i32 = 0; while (i < list.*.ops.len) { if (display_resource_reference_valid(list.*, list.*.ops.data[i]) == 0) { @@ -340,6 +362,14 @@ fn display_list_verify(list: DisplayList) DisplayError { if (render_resource_store_verify(list.resources) != RenderResourceError.none) { return DisplayError.content_mutated; } + let resource_index: i32 = 0; + while (resource_index < list.resources.resources.len) { + if (display_resource_payload_valid( + list.resources.resources.data[resource_index]) == 0) { + return DisplayError.content_mutated; + } + resource_index = resource_index + 1; + } let hash: i64 = display_mix(17, list.resources.content_hash); let stack_depth: i32 = 0; let layer_depth: i32 = 0; diff --git a/src/render/display_list_codec.zag b/src/render/display_list_codec.zag index 0358920..a69c907 100644 --- a/src/render/display_list_codec.zag +++ b/src/render/display_list_codec.zag @@ -230,6 +230,11 @@ fn display_list_decode(bytes: ArrayList[u8]) DisplayDecodeResult { return display_decode_fail(list, DisplayCodecError.invalid_resource, offset); } let added_index: i32 = render_resource_find_index(list.resources, spec.id); + if (display_resource_payload_valid( + list.resources.resources.data[added_index]) == 0) { + return display_decode_fail(list, DisplayCodecError.invalid_payload, + payload_start as i64); + } list.resources.resources.data[added_index].revision = stored_resource_revision; list.resources.resources.data[added_index].content_hash = render_resource_hash(list.resources.resources.data[added_index]); diff --git a/src/render/path.zag b/src/render/path.zag new file mode 100644 index 0000000..3281719 --- /dev/null +++ b/src/render/path.zag @@ -0,0 +1,396 @@ +@import("std:list") +@import("../core/geometry.zag") + +enum PathFillRule { + non_zero, + even_odd, +} + +enum PathVerb { + move_to, + line_to, + quad_to, + cubic_to, + close, +} + +enum PathError { + none, + sealed, + not_sealed, + empty, + invalid_fill_rule, + invalid_verb, + invalid_geometry, + invalid_sequence, + too_many_commands, + content_mutated, + bad_magic, + unsupported_version, + truncated, + trailing_data, + hash_mismatch, +} + +struct PathPoint { + x: i64, + y: i64, +} + +struct PathCommand { + verb: PathVerb, + point_1: PathPoint, + point_2: PathPoint, + point_3: PathPoint, +} + +struct PathData { + commands: ArrayList[PathCommand], + fill_rule: PathFillRule, + sealed: i32, + content_hash: i64, + last_error: PathError, + error_index: i32, +} + +struct PathEncodeResult { + bytes: ArrayList[u8], + error: PathError, +} + +struct PathDecodeResult { + path: PathData, + error: PathError, + error_offset: i64, +} + +fn path_resource_format_tag() i64 { return 1; } +fn path_codec_version() i64 { return 1; } +fn path_codec_header_size() i64 { return 40; } +fn path_codec_command_size() i64 { return 56; } +fn path_command_limit() i32 { return 65536; } + +fn path_point(x: i64, y: i64) PathPoint { + return PathPoint{ .x = x, .y = y }; +} + +fn path_zero_point() PathPoint { return path_point(0, 0); } + +fn path_command(verb: PathVerb) PathCommand { + return PathCommand{ + .verb = verb, + .point_1 = path_zero_point(), + .point_2 = path_zero_point(), + .point_3 = path_zero_point(), + }; +} + +fn path_mix(hash: i64, value: i64) i64 { + let modulus: i64 = 2147483647; + let component: i64 = value % modulus; + if (component < 0) { component = component + modulus; } + return (hash * 131 + component) % modulus; +} + +fn path_hash_command(hash: i64, command: PathCommand) i64 { + let result: i64 = path_mix(hash, command.verb as i64); + result = path_mix(result, command.point_1.x); + result = path_mix(result, command.point_1.y); + result = path_mix(result, command.point_2.x); + result = path_mix(result, command.point_2.y); + result = path_mix(result, command.point_3.x); + return path_mix(result, command.point_3.y); +} + +fn path_calculate_hash(path: PathData) i64 { + let hash: i64 = path_mix(17, path.fill_rule as i64); + hash = path_mix(hash, path.commands.len as i64); + let i: i32 = 0; + while (i < path.commands.len) { + hash = path_hash_command(hash, path.commands.data[i]); + i = i + 1; + } + return hash; +} + +fn path_make(fill_rule: PathFillRule) PathData { + return PathData{ + .commands = make[PathCommand](16), + .fill_rule = fill_rule, + .sealed = 0, + .content_hash = 0, + .last_error = PathError.none, + .error_index = 0 - 1, + }; +} + +fn path_free(path: *PathData) void { + free[PathCommand](&path.*.commands); +} + +fn path_fail(path: *PathData, error: PathError) PathError { + path.*.last_error = error; + path.*.error_index = path.*.commands.len; + return error; +} + +fn path_fixed_valid(value: i64) i32 { + return (value >= 0 - unbounded_extent() && value <= unbounded_extent()) as i32; +} + +fn path_point_valid(point: PathPoint) i32 { + return (path_fixed_valid(point.x) != 0 && path_fixed_valid(point.y) != 0) as i32; +} + +fn path_point_zero(point: PathPoint) i32 { + return (point.x == 0 && point.y == 0) as i32; +} + +fn path_command_canonical(command: PathCommand) i32 { + if (path_point_valid(command.point_1) == 0 || + path_point_valid(command.point_2) == 0 || + path_point_valid(command.point_3) == 0) { return 0; } + if (command.verb == PathVerb.move_to || command.verb == PathVerb.line_to) { + return (path_point_zero(command.point_2) != 0 && + path_point_zero(command.point_3) != 0) as i32; + } + if (command.verb == PathVerb.quad_to) { + return path_point_zero(command.point_3); + } + if (command.verb == PathVerb.cubic_to) { return 1; } + if (command.verb == PathVerb.close) { + return (path_point_zero(command.point_1) != 0 && + path_point_zero(command.point_2) != 0 && + path_point_zero(command.point_3) != 0) as i32; + } + return 0; +} + +fn path_sequence_valid(path: PathData) i32 { + if ((path.fill_rule as i64) < 0 || (path.fill_rule as i64) > 1 || + path.commands.len <= 0 || path.commands.len > path_command_limit()) { return 0; } + let contour_open: i32 = 0; + let i: i32 = 0; + while (i < path.commands.len) { + let command: PathCommand = path.commands.data[i]; + if (path_command_canonical(command) == 0) { return 0; } + if (command.verb == PathVerb.move_to) { + contour_open = 1; + } else if (command.verb == PathVerb.close) { + if (contour_open == 0) { return 0; } + contour_open = 0; + } else if (contour_open == 0) { + return 0; + } + i = i + 1; + } + return 1; +} + +fn path_push(path: *PathData, command: PathCommand) PathError { + if (path.*.sealed != 0) { return path_fail(path, PathError.sealed); } + if ((path.*.fill_rule as i64) < 0 || (path.*.fill_rule as i64) > 1) { + return path_fail(path, PathError.invalid_fill_rule); + } + if (path.*.commands.len >= path_command_limit()) { + return path_fail(path, PathError.too_many_commands); + } + if (path_command_canonical(command) == 0) { + return path_fail(path, PathError.invalid_geometry); + } + let contour_open: i32 = 0; + if (path.*.commands.len > 0) { + let i: i32 = path.*.commands.len - 1; + while (i >= 0) { + let previous: PathVerb = path.*.commands.data[i].verb; + if (previous == PathVerb.close) { break; } + if (previous == PathVerb.move_to) { contour_open = 1; break; } + i = i - 1; + } + } + if (command.verb == PathVerb.close) { + if (contour_open == 0) { return path_fail(path, PathError.invalid_sequence); } + } else if (command.verb != PathVerb.move_to && contour_open == 0) { + return path_fail(path, PathError.invalid_sequence); + } + push[PathCommand](&path.*.commands, command); + path.*.last_error = PathError.none; + path.*.error_index = 0 - 1; + return PathError.none; +} + +fn path_move_to(path: *PathData, point: PathPoint) PathError { + let command: PathCommand = path_command(PathVerb.move_to); + command.point_1 = point; + return path_push(path, command); +} + +fn path_line_to(path: *PathData, point: PathPoint) PathError { + let command: PathCommand = path_command(PathVerb.line_to); + command.point_1 = point; + return path_push(path, command); +} + +fn path_quad_to(path: *PathData, control: PathPoint, end: PathPoint) PathError { + let command: PathCommand = path_command(PathVerb.quad_to); + command.point_1 = control; + command.point_2 = end; + return path_push(path, command); +} + +fn path_cubic_to(path: *PathData, control_1: PathPoint, + control_2: PathPoint, end: PathPoint) PathError { + let command: PathCommand = path_command(PathVerb.cubic_to); + command.point_1 = control_1; + command.point_2 = control_2; + command.point_3 = end; + return path_push(path, command); +} + +fn path_close(path: *PathData) PathError { + return path_push(path, path_command(PathVerb.close)); +} + +fn path_seal(path: *PathData) PathError { + if (path.*.sealed != 0) { return PathError.none; } + if (path.*.commands.len == 0) { return path_fail(path, PathError.empty); } + if (path_sequence_valid(path.*) == 0) { + return path_fail(path, PathError.invalid_sequence); + } + path.*.content_hash = path_calculate_hash(path.*); + path.*.sealed = 1; + path.*.last_error = PathError.none; + path.*.error_index = 0 - 1; + return PathError.none; +} + +fn path_verify(path: PathData) PathError { + if (path.sealed == 0) { return PathError.not_sealed; } + if (path_sequence_valid(path) == 0 || + path_calculate_hash(path) != path.content_hash) { + return PathError.content_mutated; + } + return PathError.none; +} + +fn path_write_i64(bytes: *ArrayList[u8], value: i64) void { + let i: i32 = 0; + while (i < 8) { + push[u8](bytes, ((value >> (i * 8)) & 255) as u8); + i = i + 1; + } +} + +fn path_read_i64_bytes(bytes: []u8, offset: i64) i64 { + let value: i64 = 0; + let i: i32 = 0; + while (i < 8) { + value = value | ((bytes[(offset as i32) + i] as i64) << (i * 8)); + i = i + 1; + } + return value; +} + +fn path_encode(path: PathData) PathEncodeResult { + if (path.sealed == 0) { + return PathEncodeResult{ .bytes = make[u8](1), .error = PathError.not_sealed }; + } + if (path_verify(path) != PathError.none) { + return PathEncodeResult{ .bytes = make[u8](1), .error = PathError.content_mutated }; + } + let size: i64 = path_codec_header_size() + + (path.commands.len as i64) * path_codec_command_size(); + let bytes: ArrayList[u8] = make[u8](size as i32); + push[u8](&bytes, 90); push[u8](&bytes, 75); push[u8](&bytes, 80); push[u8](&bytes, 65); + push[u8](&bytes, 84); push[u8](&bytes, 72); push[u8](&bytes, 48); push[u8](&bytes, 49); + path_write_i64(&bytes, path_codec_version()); + path_write_i64(&bytes, path.fill_rule as i64); + path_write_i64(&bytes, path.commands.len as i64); + path_write_i64(&bytes, path.content_hash); + let i: i32 = 0; + while (i < path.commands.len) { + let command: PathCommand = path.commands.data[i]; + path_write_i64(&bytes, command.verb as i64); + path_write_i64(&bytes, command.point_1.x); + path_write_i64(&bytes, command.point_1.y); + path_write_i64(&bytes, command.point_2.x); + path_write_i64(&bytes, command.point_2.y); + path_write_i64(&bytes, command.point_3.x); + path_write_i64(&bytes, command.point_3.y); + i = i + 1; + } + return PathEncodeResult{ .bytes = bytes, .error = PathError.none }; +} + +fn path_encode_free(result: *PathEncodeResult) void { + free[u8](&result.*.bytes); +} + +fn path_decode_fail(path: PathData, error: PathError, offset: i64) PathDecodeResult { + return PathDecodeResult{ .path = path, .error = error, .error_offset = offset }; +} + +fn path_decode(payload: []u8) PathDecodeResult { + let path: PathData = path_make(PathFillRule.non_zero); + if ((payload.len as i64) < path_codec_header_size()) { + return path_decode_fail(path, PathError.truncated, payload.len as i64); + } + if (payload[0] != 90 || payload[1] != 75 || payload[2] != 80 || payload[3] != 65 || + payload[4] != 84 || payload[5] != 72 || payload[6] != 48 || payload[7] != 49) { + return path_decode_fail(path, PathError.bad_magic, 0); + } + if (path_read_i64_bytes(payload, 8) != path_codec_version()) { + return path_decode_fail(path, PathError.unsupported_version, 8); + } + let fill_code: i64 = path_read_i64_bytes(payload, 16); + if (fill_code < 0 || fill_code > 1) { + return path_decode_fail(path, PathError.invalid_fill_rule, 16); + } + let count: i64 = path_read_i64_bytes(payload, 24); + if (count <= 0 || count > path_command_limit() as i64) { + return path_decode_fail(path, PathError.too_many_commands, 24); + } + let expected: i64 = path_codec_header_size() + count * path_codec_command_size(); + if ((payload.len as i64) < expected) { + return path_decode_fail(path, PathError.truncated, payload.len as i64); + } + if ((payload.len as i64) > expected) { + return path_decode_fail(path, PathError.trailing_data, expected); + } + path.fill_rule = fill_code as PathFillRule; + let offset: i64 = path_codec_header_size(); + let i: i64 = 0; + while (i < count) { + let verb_code: i64 = path_read_i64_bytes(payload, offset); + if (verb_code < 0 || verb_code > 4) { + return path_decode_fail(path, PathError.invalid_verb, offset); + } + let command: PathCommand = PathCommand{ + .verb = verb_code as PathVerb, + .point_1 = path_point(path_read_i64_bytes(payload, offset + 8), path_read_i64_bytes(payload, offset + 16)), + .point_2 = path_point(path_read_i64_bytes(payload, offset + 24), path_read_i64_bytes(payload, offset + 32)), + .point_3 = path_point(path_read_i64_bytes(payload, offset + 40), path_read_i64_bytes(payload, offset + 48)), + }; + let error: PathError = path_push(&path, command); + if (error != PathError.none) { + return path_decode_fail(path, error, offset); + } + offset = offset + path_codec_command_size(); + i = i + 1; + } + let stored_hash: i64 = path_read_i64_bytes(payload, 32); + if (path_seal(&path) != PathError.none) { + return path_decode_fail(path, PathError.invalid_sequence, offset); + } + if (path.content_hash != stored_hash) { + return path_decode_fail(path, PathError.hash_mismatch, 32); + } + return PathDecodeResult{ .path = path, .error = PathError.none, .error_offset = 0 - 1 }; +} + +fn path_payload_valid(payload: []u8) i32 { + let decoded: PathDecodeResult = path_decode(payload); + let ok: i32 = (decoded.error == PathError.none) as i32; + path_free(&decoded.path); + return ok; +} diff --git a/tests/cpu_raster_contract.zag b/tests/cpu_raster_contract.zag index 571534b..bf6a275 100644 --- a/tests/cpu_raster_contract.zag +++ b/tests/cpu_raster_contract.zag @@ -16,6 +16,18 @@ fn pixel(surface: CpuSurface, x: i32, y: i32, channel: i32) i64 { return surface.pixels.data[(y * surface.width + x) * 4 + channel] as i64; } +fn cpu_path_payload() PathEncodeResult { + let path: PathData = path_make(PathFillRule.non_zero); + _ = path_move_to(&path, path_point(0, 0)); + _ = path_line_to(&path, path_point(unit_scale(), 0)); + _ = path_line_to(&path, path_point(0, unit_scale())); + _ = path_close(&path); + _ = path_seal(&path); + let encoded: PathEncodeResult = path_encode(path); + path_free(&path); + return encoded; +} + fn fill_scene(bounds: Rect, paint: Paint) DisplayList { let list: DisplayList = display_list_make(); let op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(1)); @@ -101,8 +113,11 @@ fn failure_contract(state: *TestState) void { display_list_free(&unsealed); let unsupported: DisplayList = display_list_make(); + let path_payload: PathEncodeResult = cpu_path_payload(); _ = display_list_add_resource(&unsupported, - render_resource_spec(8, RenderResourceKind.path, "PATH", 1)); + render_resource_spec(8, RenderResourceKind.path, + path_payload.bytes.data[0..path_payload.bytes.len], path_resource_format_tag())); + path_encode_free(&path_payload); let path: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(1)); path.resource_id = 8; _ = display_list_push(&unsupported, path); diff --git a/tests/display_list_codec_contract.zag b/tests/display_list_codec_contract.zag index 294c1b7..29677dd 100644 --- a/tests/display_list_codec_contract.zag +++ b/tests/display_list_codec_contract.zag @@ -12,16 +12,34 @@ fn expect(state: *TestState, condition: i32, name: []u8) void { } } +fn codec_path_payload(delta: i64) PathEncodeResult { + let path: PathData = path_make(PathFillRule.even_odd); + _ = path_move_to(&path, path_point(0 - unit_scale(), 0)); + _ = path_line_to(&path, path_point(4 * unit_scale() + delta, 0)); + _ = path_line_to(&path, path_point(0, 4 * unit_scale())); + _ = path_close(&path); + _ = path_seal(&path); + let encoded: PathEncodeResult = path_encode(path); + path_free(&path); + return encoded; +} + fn codec_scene() DisplayList { let list: DisplayList = display_list_make(); let owner: NodeKey = node_key_scoped(42, 3); + let initial_payload: PathEncodeResult = codec_path_payload(0); _ = display_list_add_resource(&list, - render_resource_spec(50, RenderResourceKind.path, "M0L1", 7)); + render_resource_spec(50, RenderResourceKind.path, + initial_payload.bytes.data[0..initial_payload.bytes.len], path_resource_format_tag())); + path_encode_free(&initial_payload); + let replacement_payload: PathEncodeResult = codec_path_payload(unit_scale()); let replacement: RenderResourceSpec = render_resource_spec( - 50, RenderResourceKind.path, "M0L2", 7 + 50, RenderResourceKind.path, + replacement_payload.bytes.data[0..replacement_payload.bytes.len], path_resource_format_tag() ); replacement.revision = 1; _ = display_list_replace_resource(&list, replacement); + path_encode_free(&replacement_payload); _ = display_list_push(&list, display_op(DisplayOpKind.save, owner)); let clip: DisplayOp = display_op(DisplayOpKind.clip_rect, owner); clip.bounds = rect(0 - unit_scale(), 2 * unit_scale(), 80 * unit_scale(), 40 * unit_scale()); @@ -52,7 +70,7 @@ fn round_trip_contract(state: *TestState) void { let source: DisplayList = codec_scene(); let encoded: DisplayEncodeResult = display_list_encode(source); expect(state, encoded.error == DisplayCodecError.none && encoded.bytes.len == - 76 + 64 + 4 + 5 * 160, + 76 + 64 + source.resources.resources.data[0].payload.len + 5 * 160, "sealed display list encodes resource records payload bytes and operations to the exact size"); expect(state, encoded.bytes.data[0] == 90 && encoded.bytes.data[1] == 75 && encoded.bytes.data[2] == 68 && encoded.bytes.data[3] == 76, "codec carries the ZKDL magic"); @@ -64,8 +82,9 @@ fn round_trip_contract(state: *TestState) void { expect(state, decoded.list.resources.resources.len == 1 && decoded.list.resources.resources.data[0].id == 50 && decoded.list.resources.resources.data[0].revision == 1 && - decoded.list.resources.resources.data[0].payload.len == 4 && - decoded.list.resources.resources.data[0].payload.data[3] == 50 && + decoded.list.resources.resources.data[0].payload.len == + source.resources.resources.data[0].payload.len && + decoded.list.resources.resources.data[0].format_tag == path_resource_format_tag() && decoded.list.resources.revision == 2, "round trip preserves replacement revision metadata and exact owned payload bytes"); @@ -95,7 +114,7 @@ fn malformed_contract(state: *TestState) void { display_decode_free(&bad_version); encoded.bytes.data[4] = saved_version; - let operation_offset: i32 = 76 + 64 + 4; + let operation_offset: i32 = 76 + 64 + source.resources.resources.data[0].payload.len; let saved_kind: u8 = encoded.bytes.data[operation_offset]; encoded.bytes.data[operation_offset] = 99; let bad_kind: DisplayDecodeResult = display_list_decode(encoded.bytes); @@ -174,13 +193,14 @@ fn malformed_contract(state: *TestState) void { display_decode_free(&missing_reference); encoded.bytes.data[reference_offset] = saved_reference; - let saved_payload: u8 = encoded.bytes.data[76 + 64]; - encoded.bytes.data[76 + 64] = saved_payload + 1; + let saved_payload: u8 = encoded.bytes.data[76 + 64 + 8]; + encoded.bytes.data[76 + 64 + 8] = saved_payload + 1; let bad_payload_hash: DisplayDecodeResult = display_list_decode(encoded.bytes); - expect(state, bad_payload_hash.error == DisplayCodecError.hash_mismatch, - "resource payload tampering fails aggregate display identity"); + expect(state, bad_payload_hash.error == DisplayCodecError.invalid_payload && + bad_payload_hash.error_offset == 76 + 64, + "malformed typed path payloads fail at their resource payload before operation decoding"); display_decode_free(&bad_payload_hash); - encoded.bytes.data[76 + 64] = saved_payload; + encoded.bytes.data[76 + 64 + 8] = saved_payload; let truncated: ArrayList[u8] = make[u8](20); let i: i32 = 0; @@ -219,12 +239,9 @@ fn resource_payload_sweep(state: *TestState) void { } let list: DisplayList = display_list_make_with_resource_limits(4, 256, 128); if (display_list_add_resource(&list, render_resource_spec( - 70, RenderResourceKind.path, payload.data[0..payload.len], 9 + 70, RenderResourceKind.effect, payload.data[0..payload.len], 9 )) != RenderResourceError.none) { stable = 0; } - let path: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(70)); - path.resource_id = 70; - if (display_list_push(&list, path) != DisplayError.none || - display_list_seal(&list) != DisplayError.none) { stable = 0; } + if (display_list_seal(&list) != DisplayError.none) { stable = 0; } let encoded: DisplayEncodeResult = display_list_encode(list); let decoded: DisplayDecodeResult = display_list_decode(encoded.bytes); if (encoded.error != DisplayCodecError.none || diff --git a/tests/display_list_contract.zag b/tests/display_list_contract.zag index b09cd1e..9b08a39 100644 --- a/tests/display_list_contract.zag +++ b/tests/display_list_contract.zag @@ -12,11 +12,26 @@ fn expect(state: *TestState, condition: i32, name: []u8) void { } } +fn scene_path_payload(delta: i64) PathEncodeResult { + let path: PathData = path_make(PathFillRule.non_zero); + _ = path_move_to(&path, path_point(0, 0)); + _ = path_line_to(&path, path_point(10 * unit_scale() + delta, 0)); + _ = path_line_to(&path, path_point(5 * unit_scale(), 8 * unit_scale())); + _ = path_close(&path); + _ = path_seal(&path); + let encoded: PathEncodeResult = path_encode(path); + path_free(&path); + return encoded; +} + fn build_scene(color: i64) DisplayList { let list: DisplayList = display_list_make(); let owner: NodeKey = node_key(7); + let path_payload: PathEncodeResult = scene_path_payload(0); _ = display_list_add_resource(&list, - render_resource_spec(101, RenderResourceKind.path, "PATH", 1)); + render_resource_spec(101, RenderResourceKind.path, + path_payload.bytes.data[0..path_payload.bytes.len], path_resource_format_tag())); + path_encode_free(&path_payload); let image_resource: RenderResourceSpec = render_resource_spec( 202, RenderResourceKind.image, "RGBA", 2 ); @@ -156,6 +171,17 @@ fn rejection_contract(state: *TestState) void { mismatched.error_index == 0, "resource kind mismatches cannot masquerade as drawable payloads"); display_list_free(&mismatched); + + let malformed: DisplayList = display_list_make(); + _ = display_list_add_resource(&malformed, + render_resource_spec(6, RenderResourceKind.path, "NOT-A-PATH", path_resource_format_tag())); + let malformed_path: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(4)); + malformed_path.resource_id = 6; + _ = display_list_push(&malformed, malformed_path); + expect(state, display_list_seal(&malformed) == DisplayError.invalid_resource && + malformed.error_index == 0 - 1, + "canonical path validation rejects opaque owned bytes before operation validation"); + display_list_free(&malformed); } fn deterministic_sweep(state: *TestState) void { diff --git a/tests/path_contract.zag b/tests/path_contract.zag new file mode 100644 index 0000000..f5a6bf3 --- /dev/null +++ b/tests/path_contract.zag @@ -0,0 +1,197 @@ +@import("../src/render/path.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn bytes_equal(a: ArrayList[u8], b: ArrayList[u8]) i32 { + if (a.len != b.len) { return 0; } + let i: i32 = 0; + while (i < a.len) { + if (a.data[i] != b.data[i]) { return 0; } + i = i + 1; + } + return 1; +} + +fn complete_path(fill_rule: PathFillRule) PathData { + let path: PathData = path_make(fill_rule); + _ = path_move_to(&path, path_point(0 - unit_scale(), 2 * unit_scale())); + _ = path_line_to(&path, path_point(3 * unit_scale(), 2 * unit_scale())); + _ = path_quad_to(&path, path_point(4 * unit_scale(), 3 * unit_scale()), + path_point(3 * unit_scale(), 4 * unit_scale())); + _ = path_cubic_to(&path, + path_point(2 * unit_scale(), 5 * unit_scale()), + path_point(0, 5 * unit_scale()), + path_point(0 - unit_scale(), 4 * unit_scale())); + _ = path_close(&path); + _ = path_move_to(&path, path_point(0, 0)); + _ = path_line_to(&path, path_point(unit_scale(), unit_scale())); + _ = path_seal(&path); + return path; +} + +fn builder_contract(state: *TestState) void { + let path: PathData = path_make(PathFillRule.non_zero); + expect(state, path_line_to(&path, path_point(1, 1)) == PathError.invalid_sequence && + path.commands.len == 0, "line commands require an open contour"); + expect(state, path_close(&path) == PathError.invalid_sequence && path.commands.len == 0, + "close commands require an open contour"); + expect(state, path_move_to(&path, path_point(0, 0)) == PathError.none && + path_line_to(&path, path_point(unit_scale(), 0)) == PathError.none && + path_close(&path) == PathError.none, + "move line and close build a canonical contour"); + expect(state, path_line_to(&path, path_point(0, unit_scale())) == PathError.invalid_sequence, + "closed contours require a new move before geometry"); + expect(state, path_move_to(&path, path_point(unbounded_extent() + 1, 0)) == + PathError.invalid_geometry, "coordinates outside the shared fixed-point domain fail before ownership changes"); + expect(state, path_seal(&path) == PathError.none && path_verify(path) == PathError.none, + "valid paths seal with deterministic immutable identity"); + expect(state, path_move_to(&path, path_point(1, 1)) == PathError.sealed, + "sealed paths reject builder mutation"); + let original: i64 = path.commands.data[1].point_1.x; + path.commands.data[1].point_1.x = original + 1; + expect(state, path_verify(path) == PathError.content_mutated, + "raw command mutation is detected by immutable verification"); + path.commands.data[1].point_1.x = original; + expect(state, path_verify(path) == PathError.none, + "restoring exact command bytes restores path identity"); + path_free(&path); + + let empty: PathData = path_make(PathFillRule.even_odd); + expect(state, path_seal(&empty) == PathError.empty && empty.sealed == 0, + "empty paths cannot claim renderable identity"); + path_free(&empty); +} + +fn codec_contract(state: *TestState) void { + let source: PathData = complete_path(PathFillRule.even_odd); + let encoded: PathEncodeResult = path_encode(source); + expect(state, encoded.error == PathError.none && encoded.bytes.len == + path_codec_header_size() as i32 + source.commands.len * path_codec_command_size() as i32, + "canonical path encoding has one bounded fixed-width record per command"); + expect(state, encoded.bytes.data[0] == 90 && encoded.bytes.data[1] == 75 && + encoded.bytes.data[6] == 48 && encoded.bytes.data[7] == 49, + "path bytes carry the ZKPATH01 magic"); + let decoded: PathDecodeResult = path_decode(encoded.bytes.data[0..encoded.bytes.len]); + expect(state, decoded.error == PathError.none && decoded.path.sealed == 1 && + decoded.path.fill_rule == PathFillRule.even_odd, + "valid bytes decode to the requested fill rule and a sealed path"); + expect(state, decoded.path.commands.len == source.commands.len && + decoded.path.commands.data[0].point_1.x == 0 - unit_scale() && + decoded.path.commands.data[3].verb == PathVerb.cubic_to, + "round trip preserves signed coordinates and every curve verb"); + expect(state, decoded.path.content_hash == source.content_hash, + "round trip preserves deterministic path identity"); + let encoded_again: PathEncodeResult = path_encode(decoded.path); + expect(state, bytes_equal(encoded.bytes, encoded_again.bytes) != 0, + "decode and re-encode are byte identical"); + path_encode_free(&encoded_again); + path_free(&decoded.path); + path_encode_free(&encoded); + path_free(&source); +} + +fn malformed_contract(state: *TestState) void { + let source: PathData = complete_path(PathFillRule.non_zero); + let encoded: PathEncodeResult = path_encode(source); + + let saved_magic: u8 = encoded.bytes.data[0]; + encoded.bytes.data[0] = 0; + let bad_magic: PathDecodeResult = path_decode(encoded.bytes.data[0..encoded.bytes.len]); + expect(state, bad_magic.error == PathError.bad_magic && bad_magic.error_offset == 0, + "bad path magic fails at byte zero"); + path_free(&bad_magic.path); encoded.bytes.data[0] = saved_magic; + + let saved_version: u8 = encoded.bytes.data[8]; + encoded.bytes.data[8] = 2; + let bad_version: PathDecodeResult = path_decode(encoded.bytes.data[0..encoded.bytes.len]); + expect(state, bad_version.error == PathError.unsupported_version && bad_version.error_offset == 8, + "unknown path versions fail visibly"); + path_free(&bad_version.path); encoded.bytes.data[8] = saved_version; + + let saved_fill: u8 = encoded.bytes.data[16]; + encoded.bytes.data[16] = 2; + let bad_fill: PathDecodeResult = path_decode(encoded.bytes.data[0..encoded.bytes.len]); + expect(state, bad_fill.error == PathError.invalid_fill_rule && bad_fill.error_offset == 16, + "unknown fill rules fail before command allocation"); + path_free(&bad_fill.path); encoded.bytes.data[16] = saved_fill; + + let first_command: i32 = path_codec_header_size() as i32; + let saved_verb: u8 = encoded.bytes.data[first_command]; + encoded.bytes.data[first_command] = 9; + let bad_verb: PathDecodeResult = path_decode(encoded.bytes.data[0..encoded.bytes.len]); + expect(state, bad_verb.error == PathError.invalid_verb && + bad_verb.error_offset == first_command as i64, + "unknown path verbs fail at the exact command record"); + path_free(&bad_verb.path); encoded.bytes.data[first_command] = saved_verb; + + let unused_coordinate: i32 = first_command + 24; + let saved_unused: u8 = encoded.bytes.data[unused_coordinate]; + encoded.bytes.data[unused_coordinate] = 1; + let noncanonical: PathDecodeResult = path_decode(encoded.bytes.data[0..encoded.bytes.len]); + expect(state, noncanonical.error == PathError.invalid_geometry && + noncanonical.error_offset == first_command as i64, + "unused coordinate fields must be canonical zeroes"); + path_free(&noncanonical.path); encoded.bytes.data[unused_coordinate] = saved_unused; + + let saved_hash: u8 = encoded.bytes.data[32]; + encoded.bytes.data[32] = saved_hash + 1; + let bad_hash: PathDecodeResult = path_decode(encoded.bytes.data[0..encoded.bytes.len]); + expect(state, bad_hash.error == PathError.hash_mismatch && bad_hash.error_offset == 32, + "path payload tampering fails deterministic identity verification"); + path_free(&bad_hash.path); encoded.bytes.data[32] = saved_hash; + + let truncated: PathDecodeResult = path_decode(encoded.bytes.data[0..encoded.bytes.len - 1]); + expect(state, truncated.error == PathError.truncated && + truncated.error_offset == encoded.bytes.len as i64 - 1, + "truncated command data reports the observed length"); + path_free(&truncated.path); + + push[u8](&encoded.bytes, 0); + let trailing: PathDecodeResult = path_decode(encoded.bytes.data[0..encoded.bytes.len]); + expect(state, trailing.error == PathError.trailing_data, + "trailing path bytes fail canonical decoding"); + path_free(&trailing.path); + + path_encode_free(&encoded); + path_free(&source); +} + +fn deterministic_sweep(state: *TestState) void { + let expected: i64 = 0; + let stable: i32 = 1; + let i: i32 = 0; + while (i < 100) { + let path: PathData = complete_path(PathFillRule.non_zero); + if (i == 0) { expected = path.content_hash; } + if (path.content_hash != expected || path_verify(path) != PathError.none) { stable = 0; } + path_free(&path); + i = i + 1; + } + expect(state, stable, "100-build sweep preserves canonical path identity"); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + builder_contract(&state); + codec_contract(&state); + malformed_contract(&state); + deterministic_sweep(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Path contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); + _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 82cb4f2..f088027 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -38,6 +38,8 @@ cd "$root" "$tmp/talkback-contract" "$znc" tests/render_resources_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/render-resources-contract" "$tmp/render-resources-contract" +"$znc" tests/path_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/path-contract" +"$tmp/path-contract" "$znc" tests/display_list_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/display-list-contract" "$tmp/display-list-contract" "$znc" tests/display_list_codec_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/display-list-codec-contract" @@ -51,4 +53,4 @@ cd "$root" "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, display lists, CPU raster, input, replay, and motion)\n' +printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths, display lists, CPU raster, input, replay, and motion)\n' From 0483fd256722df61087bb849341c5dae822e7696 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 09:27:16 -0700 Subject: [PATCH 025/103] Rasterize canonical paths in CPU oracle --- CHANGELOG.md | 4 +- README.md | 12 +- ROADMAP.md | 2 +- docs/architecture/README.md | 12 +- docs/architecture/paths.md | 17 +- docs/architecture/render-resources.md | 4 +- src/render/cpu_raster.zag | 289 ++++++++++++++++++++++++++ src/replay/replay.zag | 39 +++- tests/cpu_raster_contract.zag | 175 ++++++++++++++-- tools/test-headless.sh | 2 +- 10 files changed, 520 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d1bc08..5265b88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,9 @@ include a generated capability record and exact Zag compiler revision. byte-identical replay round trips, upgraded to ZKDL version 2 with owned resource metadata, payload, allocation-policy, and revision serialization. - The first deterministic CPU-oracle subset for fixed-point fills, clipping, - axis-aligned transforms, fractional coverage, and source-over alpha. + axis-aligned transforms, fractional coverage, and source-over alpha, extended + with bounded canonical path fills, quadratic and cubic flattening, non-zero + and even-odd winding, 8 by 8 coverage, and fail-before-pixel work ceilings. - Strict executable headless contracts in CI using the exact pinned Zag revision rather than an ambient compiler. - Transformed hit testing and deterministic pointer routing with clipping, diff --git a/README.md b/README.md index e5b8982..b9076aa 100644 --- a/README.md +++ b/README.md @@ -148,11 +148,13 @@ rasterization, other payload interpretation, damage, complete CPU rasterization, GPU transport, schema evolution, and fuzz coverage remain open. See the [path contract](docs/architecture/paths.md). -The first CPU-oracle subset rasterizes fixed-point rectangle fills with exact -clip and axis-aligned transform state, area-based fractional edge coverage, and -deterministic source-over alpha into owned RGBA8 surfaces. Unsupported paths, -images, glyphs, strokes, skew, layers, and effects fail at the exact operation; -this subset is not yet the complete CPU renderer required by Milestone 2. +The first CPU-oracle subset rasterizes fixed-point rectangle and canonical path +fills with exact clip and axis-aligned transform state, area-based rectangle +edges, deterministic 8 by 8 path coverage, non-zero and even-odd winding, +bounded curve flattening, and source-over alpha into owned RGBA8 surfaces. +Path edge and work budgets fail before pixel mutation. Unsupported images, +glyphs, strokes, skew, layers, and effects fail at the exact operation; this +subset is not yet the complete CPU renderer required by Milestone 2. The first input slice resolves full affine transforms back to local coordinates, honors local clips and z-order, rejects singular or malformed hit nodes, and diff --git a/ROADMAP.md b/ROADMAP.md index ae19268..cda18c9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,7 +41,7 @@ affected Zagkit conformance. Strict native Zag contracts currently execute state and dependency tracking, keyed reconciliation, bounded geometry, retained intrinsic measurement, adaptive and wrapped Flex, semantics, ID-first in-process Talkback dispatch, -immutable display lists and their codec, a rectangle CPU-oracle subset, +immutable display lists and their codec, a rectangle and path CPU-oracle subset, transformed pointer routing, deterministic replay, and motion kernels. This is real headless execution but does not satisfy the milestone exit gate yet. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 0228d05..3d90eb9 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -88,11 +88,13 @@ the stored content identity and revision, and rejects trailing bytes so one scene has one canonical encoding. A bounded variable-length resource section preserves allocation policy, typed metadata, revisions, and exact owned bytes. -The CPU oracle begins with deterministic RGBA8 rectangle rasterization from -26.6 fixed-point geometry. Clip and positive axis-aligned transform state, -fractional edge coverage, and source-over alpha are integer-only. Every -unsupported display operation fails at its exact index instead of silently -degrading or claiming a visual result. +The CPU oracle begins with deterministic RGBA8 rectangle and canonical path +fills from 26.6 fixed-point geometry. Clip and positive axis-aligned transform +state, fractional rectangle edges, 8 by 8 path coverage, non-zero and even-odd +winding, fixed curve flattening, and source-over alpha are integer-only. +Explicit edge and work ceilings reject pathological path scenes before pixel +mutation. Every unsupported display operation fails at its exact index instead +of silently degrading or claiming a visual result. The experimental input router consumes a parallel retained `HitTree`. It uses integer affine inversion for local coordinates, resolves local clips and diff --git a/docs/architecture/paths.md b/docs/architecture/paths.md index 8441076..69160d2 100644 --- a/docs/architecture/paths.md +++ b/docs/architecture/paths.md @@ -36,7 +36,16 @@ owned path payload once, then validates operation references by stable ID, resource kind, and format. This avoids both opaque-path acceptance and repeated decode work when one path is drawn many times. -This contract does not yet flatten curves, compute analytic curve extrema, -rasterize fills or strokes, apply dashes, perform boolean path operations, or -decode SVG path syntax. Those remain unavailable until their deterministic, -malformed-input, golden, and cleanup suites pass. +The CPU oracle closes open contours for filling, flattens quadratic and cubic +commands into 16 deterministic line segments, applies the retained positive +axis-aligned transform and clip, evaluates non-zero or even-odd winding, and +uses an 8 by 8 subpixel grid for symmetric coverage. It rejects transformed +coordinates, edge counts, or pixel-edge-sample work beyond explicit bounds +before touching pixels. The replay conformance scene exercises this real path +route. + +Fixed-subdivision flattening is an experimental correctness step, not the final +curve-quality contract. Scale-adaptive or analytic curve coverage, exact curve +extrema, strokes, joins, caps, dashes, boolean path operations, and SVG path +syntax remain unavailable until their deterministic, malformed-input, golden, +fidelity, and cleanup suites pass. diff --git a/docs/architecture/render-resources.md b/docs/architecture/render-resources.md index c66a234..45d626a 100644 --- a/docs/architecture/render-resources.md +++ b/docs/architecture/render-resources.md @@ -53,7 +53,7 @@ canonical resource section before operations and reconstructs it through the same bounded validation path; decoding and re-encoding are byte identical. The current executable contract still does not decode SVG or PNG, validate font -tables, interpret glyph payload schemas, rasterize paths or images, cache -platform uploads, or implement memory-pressure eviction. Those +tables, interpret glyph payload schemas, rasterize path strokes or images, +cache platform uploads, or implement memory-pressure eviction. Those capabilities remain unavailable until their own malformed-input, rendering, replacement, and cleanup suites pass. diff --git a/src/render/cpu_raster.zag b/src/render/cpu_raster.zag index b7672b0..16f3787 100644 --- a/src/render/cpu_raster.zag +++ b/src/render/cpu_raster.zag @@ -7,6 +7,7 @@ enum CpuRasterError { unsealed_display_list, mutated_display_list, invalid_geometry, + work_limit, unsupported_operation, } @@ -29,6 +30,17 @@ struct CpuRasterResult { pixels_touched: i64, } +struct CpuPathEdge { + start: PathPoint, + end: PathPoint, +} + +struct CpuPathBuildResult { + edges: ArrayList[CpuPathEdge], + bounds: Rect, + error: CpuRasterError, +} + fn cpu_surface_make(width: i32, height: i32) CpuSurface { let pixels: ArrayList[u8] = make[u8](1); if (width <= 0 || height <= 0 || width > 8192 || height > 8192) { @@ -119,6 +131,256 @@ fn cpu_compose_transform(current: Transform, next: Transform) Transform { }; } +fn cpu_path_coordinate_limit() i64 { return 1000000000; } +fn cpu_path_edge_limit() i32 { return 262144; } +fn cpu_path_sample_axis() i32 { return 8; } +fn cpu_path_work_limit() i64 { return 50000000; } + +fn cpu_path_point_safe(point: PathPoint) i32 { + let limit: i64 = cpu_path_coordinate_limit(); + return (point.x >= 0 - limit && point.x <= limit && + point.y >= 0 - limit && point.y <= limit) as i32; +} + +fn cpu_transform_path_point(point: PathPoint, transform: Transform) PathPoint { + return path_point( + (point.x * transform.scale_x) / unit_scale() + transform.translate_x, + (point.y * transform.scale_y) / unit_scale() + transform.translate_y + ); +} + +fn cpu_path_empty_build() CpuPathBuildResult { + return CpuPathBuildResult{ + .edges = make[CpuPathEdge](32), + .bounds = rect(0, 0, 0, 0), + .error = CpuRasterError.none, + }; +} + +fn cpu_path_include_point(result: *CpuPathBuildResult, point: PathPoint) void { + if (result.*.edges.len == 0 && result.*.bounds.width == 0 && + result.*.bounds.height == 0) { + result.*.bounds = rect(point.x, point.y, 0, 0); + return; + } + let left: i64 = result.*.bounds.x; + let top: i64 = result.*.bounds.y; + let right: i64 = result.*.bounds.x + result.*.bounds.width; + let bottom: i64 = result.*.bounds.y + result.*.bounds.height; + if (point.x < left) { left = point.x; } + if (point.y < top) { top = point.y; } + if (point.x > right) { right = point.x; } + if (point.y > bottom) { bottom = point.y; } + result.*.bounds = rect(left, top, right - left, bottom - top); +} + +fn cpu_path_add_edge(result: *CpuPathBuildResult, local_start: PathPoint, + local_end: PathPoint, transform: Transform) i32 { + let start: PathPoint = cpu_transform_path_point(local_start, transform); + let end: PathPoint = cpu_transform_path_point(local_end, transform); + if (cpu_path_point_safe(start) == 0 || cpu_path_point_safe(end) == 0) { + result.*.error = CpuRasterError.invalid_geometry; + return 0; + } + if (start.x == end.x && start.y == end.y) { return 1; } + if (result.*.edges.len >= cpu_path_edge_limit()) { + result.*.error = CpuRasterError.work_limit; + return 0; + } + cpu_path_include_point(result, start); + push[CpuPathEdge](&result.*.edges, CpuPathEdge{ .start = start, .end = end }); + cpu_path_include_point(result, end); + return 1; +} + +fn cpu_quad_point(start: PathPoint, control: PathPoint, end: PathPoint, + step: i32, divisions: i32) PathPoint { + let inverse: i64 = (divisions - step) as i64; + let progress: i64 = step as i64; + let denominator: i64 = (divisions as i64) * (divisions as i64); + return path_point( + (inverse * inverse * start.x + 2 * inverse * progress * control.x + + progress * progress * end.x) / denominator, + (inverse * inverse * start.y + 2 * inverse * progress * control.y + + progress * progress * end.y) / denominator + ); +} + +fn cpu_cubic_point(start: PathPoint, control_1: PathPoint, + control_2: PathPoint, end: PathPoint, step: i32, divisions: i32) PathPoint { + let inverse: i64 = (divisions - step) as i64; + let progress: i64 = step as i64; + let denominator: i64 = (divisions as i64) * (divisions as i64) * (divisions as i64); + return path_point( + (inverse * inverse * inverse * start.x + + 3 * inverse * inverse * progress * control_1.x + + 3 * inverse * progress * progress * control_2.x + + progress * progress * progress * end.x) / denominator, + (inverse * inverse * inverse * start.y + + 3 * inverse * inverse * progress * control_1.y + + 3 * inverse * progress * progress * control_2.y + + progress * progress * progress * end.y) / denominator + ); +} + +fn cpu_path_build(path: PathData, transform: Transform) CpuPathBuildResult { + let result: CpuPathBuildResult = cpu_path_empty_build(); + let current: PathPoint = path_zero_point(); + let contour_start: PathPoint = path_zero_point(); + let contour_open: i32 = 0; + let i: i32 = 0; + while (i < path.commands.len) { + let command: PathCommand = path.commands.data[i]; + if (command.verb == PathVerb.move_to) { + if (contour_open != 0 && + cpu_path_add_edge(&result, current, contour_start, transform) == 0) { + return result; + } + current = command.point_1; + contour_start = current; + contour_open = 1; + } else if (command.verb == PathVerb.line_to) { + if (cpu_path_add_edge(&result, current, command.point_1, transform) == 0) { + return result; + } + current = command.point_1; + } else if (command.verb == PathVerb.quad_to) { + let start: PathPoint = current; + let previous: PathPoint = current; + let step: i32 = 1; + while (step <= 16) { + let next: PathPoint = cpu_quad_point(start, command.point_1, + command.point_2, step, 16); + if (cpu_path_add_edge(&result, previous, next, transform) == 0) { + return result; + } + previous = next; + step = step + 1; + } + current = command.point_2; + } else if (command.verb == PathVerb.cubic_to) { + let start: PathPoint = current; + let previous: PathPoint = current; + let step: i32 = 1; + while (step <= 16) { + let next: PathPoint = cpu_cubic_point(start, command.point_1, + command.point_2, command.point_3, step, 16); + if (cpu_path_add_edge(&result, previous, next, transform) == 0) { + return result; + } + previous = next; + step = step + 1; + } + current = command.point_3; + } else if (command.verb == PathVerb.close) { + if (cpu_path_add_edge(&result, current, contour_start, transform) == 0) { + return result; + } + current = contour_start; + contour_open = 0; + } + i = i + 1; + } + if (contour_open != 0 && + cpu_path_add_edge(&result, current, contour_start, transform) == 0) { + return result; + } + return result; +} + +fn cpu_path_contains(edges: ArrayList[CpuPathEdge], fill_rule: PathFillRule, + x: i64, y: i64) i32 { + let winding: i32 = 0; + let crossings: i32 = 0; + let i: i32 = 0; + while (i < edges.len) { + let edge: CpuPathEdge = edges.data[i]; + let delta_x: i64 = edge.end.x - edge.start.x; + let delta_y: i64 = edge.end.y - edge.start.y; + let cross: i64 = delta_x * (y - edge.start.y) - + (x - edge.start.x) * delta_y; + if (edge.start.y <= y && edge.end.y > y && cross > 0) { + winding = winding + 1; + crossings = crossings + 1; + } else if (edge.end.y <= y && edge.start.y > y && cross < 0) { + winding = winding - 1; + crossings = crossings + 1; + } + i = i + 1; + } + if (fill_rule == PathFillRule.even_odd) { return (crossings % 2 != 0) as i32; } + return (winding != 0) as i32; +} + +fn cpu_fill_path(surface: *CpuSurface, path: PathData, transform: Transform, + clip: Rect, paint: Paint) CpuRasterResult { + let built: CpuPathBuildResult = cpu_path_build(path, transform); + if (built.error != CpuRasterError.none) { + free[CpuPathEdge](&built.edges); + return cpu_result(surface.*, built.error, 0 - 1, 0, 0); + } + if (built.edges.len == 0 || built.bounds.width <= 0 || built.bounds.height <= 0) { + free[CpuPathEdge](&built.edges); + return cpu_result(surface.*, CpuRasterError.none, 0 - 1, 0, 0); + } + let surface_bounds: Rect = rect(0, 0, (surface.*.width as i64) * unit_scale(), + (surface.*.height as i64) * unit_scale()); + let bounds: Rect = cpu_rect_intersection( + cpu_rect_intersection(built.bounds, clip), surface_bounds + ); + if (bounds.width <= 0 || bounds.height <= 0) { + free[CpuPathEdge](&built.edges); + return cpu_result(surface.*, CpuRasterError.none, 0 - 1, 0, 0); + } + let first_x: i32 = (bounds.x / unit_scale()) as i32; + let first_y: i32 = (bounds.y / unit_scale()) as i32; + let last_x: i32 = ((bounds.x + bounds.width + unit_scale() - 1) / unit_scale()) as i32; + let last_y: i32 = ((bounds.y + bounds.height + unit_scale() - 1) / unit_scale()) as i32; + let pixel_count: i64 = ((last_x - first_x) as i64) * ((last_y - first_y) as i64); + let samples: i64 = (cpu_path_sample_axis() as i64) * (cpu_path_sample_axis() as i64); + if (built.edges.len > 0 && (pixel_count <= 0 || + pixel_count > cpu_path_work_limit() / samples / (built.edges.len as i64))) { + free[CpuPathEdge](&built.edges); + return cpu_result(surface.*, CpuRasterError.work_limit, 0 - 1, 0, 0); + } + + let touched: i64 = 0; + let y: i32 = first_y; + while (y < last_y) { + let x: i32 = first_x; + while (x < last_x) { + let inside: i64 = 0; + let sample_y: i32 = 0; + while (sample_y < cpu_path_sample_axis()) { + let point_y: i64 = (y as i64) * unit_scale() + + ((sample_y as i64) * unit_scale() + unit_scale() / 2) / + (cpu_path_sample_axis() as i64); + let sample_x: i32 = 0; + while (sample_x < cpu_path_sample_axis()) { + let point_x: i64 = (x as i64) * unit_scale() + + ((sample_x as i64) * unit_scale() + unit_scale() / 2) / + (cpu_path_sample_axis() as i64); + if (point_x >= clip.x && point_x < clip.x + clip.width && + point_y >= clip.y && point_y < clip.y + clip.height && + cpu_path_contains(built.edges, path.fill_rule, point_x, point_y) != 0) { + inside = inside + 1; + } + sample_x = sample_x + 1; + } + sample_y = sample_y + 1; + } + if (inside > 0) { + cpu_blend_pixel(surface, x, y, paint, (inside * 4096) / samples); + touched = touched + 1; + } + x = x + 1; + } + y = y + 1; + } + free[CpuPathEdge](&built.edges); + return cpu_result(surface.*, CpuRasterError.none, 0 - 1, 0, touched); +} + fn cpu_blend_pixel(surface: *CpuSurface, x: i32, y: i32, paint: Paint, coverage: i64) void { if (coverage <= 0) { return; } let index: i32 = (y * surface.*.width + x) * 4; @@ -242,6 +504,33 @@ fn cpu_rasterize(list: DisplayList, width: i32, height: i32) CpuRasterResult { return cpu_result(surface, CpuRasterError.invalid_geometry, i, rendered_ops, pixels_touched); } pixels_touched = pixels_touched + cpu_fill_rect(&surface, transformed, current.clip, op.paint); + } else if (op.kind == DisplayOpKind.draw_path) { + let resource_index: i32 = render_resource_find_index(list.resources, op.resource_id); + if (resource_index < 0) { + free[CpuRasterState](&stack); + return cpu_result(surface, CpuRasterError.mutated_display_list, + i, rendered_ops, pixels_touched); + } + let resource: RenderResource = list.resources.resources.data[resource_index]; + let decoded: PathDecodeResult = path_decode( + resource.payload.data[0..resource.payload.len] + ); + if (decoded.error != PathError.none) { + path_free(&decoded.path); + free[CpuRasterState](&stack); + return cpu_result(surface, CpuRasterError.mutated_display_list, + i, rendered_ops, pixels_touched); + } + let path_result: CpuRasterResult = cpu_fill_path( + &surface, decoded.path, current.transform, current.clip, op.paint + ); + path_free(&decoded.path); + if (path_result.error != CpuRasterError.none) { + free[CpuRasterState](&stack); + return cpu_result(surface, path_result.error, i, + rendered_ops, pixels_touched); + } + pixels_touched = pixels_touched + path_result.pixels_touched; } else { free[CpuRasterState](&stack); return cpu_result(surface, CpuRasterError.unsupported_operation, i, rendered_ops, pixels_touched); diff --git a/src/replay/replay.zag b/src/replay/replay.zag index c117928..0e25a1b 100644 --- a/src/replay/replay.zag +++ b/src/replay/replay.zag @@ -614,6 +614,34 @@ fn replay_build_scene(state_value: i64, backend_generation: i64) ReplayResult { semantics_tree_free(&semantics); return replay_result_error(ReplayError.scene_error, 0 - 1, 0); } + let replay_path: PathData = path_make(PathFillRule.non_zero); + _ = path_move_to(&replay_path, path_point(unit_scale(), unit_scale())); + _ = path_line_to(&replay_path, + path_point((5 + magnitude) * unit_scale(), unit_scale())); + _ = path_quad_to(&replay_path, path_point(3 * unit_scale(), 5 * unit_scale()), + path_point(unit_scale(), unit_scale())); + _ = path_close(&replay_path); + if (path_seal(&replay_path) != PathError.none) { + path_free(&replay_path); + display_list_free(&list); + flex_wrap_free(&layout); + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } + let replay_path_payload: PathEncodeResult = path_encode(replay_path); + path_free(&replay_path); + let replay_path_error: RenderResourceError = display_list_add_resource(&list, + render_resource_spec(901, RenderResourceKind.path, + replay_path_payload.bytes.data[0..replay_path_payload.bytes.len], + path_resource_format_tag()) + ); + path_encode_free(&replay_path_payload); + if (replay_path_error != RenderResourceError.none) { + display_list_free(&list); + flex_wrap_free(&layout); + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } let first_op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(101)); first_op.bounds = layout.placements.data[0].frame; let blue: i64 = (backend_generation % 8) * 4096; @@ -636,7 +664,16 @@ fn replay_build_scene(state_value: i64, backend_generation: i64) ReplayResult { let third_op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(103)); third_op.bounds = layout.placements.data[2].frame; third_op.paint = paint_rgba16(12288, 40960, 24576 + magnitude * 4096, 65535); - if (display_list_push(&list, third_op) != DisplayError.none || + if (display_list_push(&list, third_op) != DisplayError.none) { + display_list_free(&list); + flex_wrap_free(&layout); + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } + let path_op: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(104)); + path_op.resource_id = 901; + path_op.paint = paint_rgba16(57344, 49152, 8192, 32768); + if (display_list_push(&list, path_op) != DisplayError.none || display_list_seal(&list) != DisplayError.none) { display_list_free(&list); flex_wrap_free(&layout); diff --git a/tests/cpu_raster_contract.zag b/tests/cpu_raster_contract.zag index bf6a275..8836d22 100644 --- a/tests/cpu_raster_contract.zag +++ b/tests/cpu_raster_contract.zag @@ -16,16 +16,37 @@ fn pixel(surface: CpuSurface, x: i32, y: i32, channel: i32) i64 { return surface.pixels.data[(y * surface.width + x) * 4 + channel] as i64; } -fn cpu_path_payload() PathEncodeResult { - let path: PathData = path_make(PathFillRule.non_zero); +fn path_scene(path: PathData, paint: Paint) DisplayList { + let list: DisplayList = display_list_make(); + let payload: PathEncodeResult = path_encode(path); + _ = display_list_add_resource(&list, + render_resource_spec(8, RenderResourceKind.path, + payload.bytes.data[0..payload.bytes.len], path_resource_format_tag())); + path_encode_free(&payload); + let draw: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(8)); + draw.resource_id = 8; + draw.paint = paint; + _ = display_list_push(&list, draw); + _ = display_list_seal(&list); + return list; +} + +fn square_path(fill_rule: PathFillRule, include_inner: i32) PathData { + let path: PathData = path_make(fill_rule); _ = path_move_to(&path, path_point(0, 0)); - _ = path_line_to(&path, path_point(unit_scale(), 0)); - _ = path_line_to(&path, path_point(0, unit_scale())); + _ = path_line_to(&path, path_point(4 * unit_scale(), 0)); + _ = path_line_to(&path, path_point(4 * unit_scale(), 4 * unit_scale())); + _ = path_line_to(&path, path_point(0, 4 * unit_scale())); _ = path_close(&path); + if (include_inner != 0) { + _ = path_move_to(&path, path_point(unit_scale(), unit_scale())); + _ = path_line_to(&path, path_point(3 * unit_scale(), unit_scale())); + _ = path_line_to(&path, path_point(3 * unit_scale(), 3 * unit_scale())); + _ = path_line_to(&path, path_point(unit_scale(), 3 * unit_scale())); + _ = path_close(&path); + } _ = path_seal(&path); - let encoded: PathEncodeResult = path_encode(path); - path_free(&path); - return encoded; + return path; } fn fill_scene(bounds: Rect, paint: Paint) DisplayList { @@ -105,6 +126,128 @@ fn deterministic_contract(state: *TestState) void { display_list_free(&list); } +fn path_fill_contract(state: *TestState) void { + let triangle: PathData = path_make(PathFillRule.non_zero); + _ = path_move_to(&triangle, path_point(0, 0)); + _ = path_line_to(&triangle, path_point(2 * unit_scale(), 0)); + _ = path_line_to(&triangle, path_point(0, 2 * unit_scale())); + _ = path_close(&triangle); + _ = path_seal(&triangle); + let triangle_list: DisplayList = path_scene(triangle, + paint_rgba16(65535, 0, 0, 65535)); + let triangle_result: CpuRasterResult = cpu_rasterize(triangle_list, 3, 3); + expect(state, triangle_result.error == CpuRasterError.none && + triangle_result.rendered_ops == 1 && triangle_result.pixels_touched == 3, + "canonical line paths rasterize as one deterministic display operation"); + expect(state, pixel(triangle_result.surface, 0, 0, 3) == 255 && + pixel(triangle_result.surface, 1, 0, 3) == 112 && + pixel(triangle_result.surface, 0, 1, 3) == 112 && + pixel(triangle_result.surface, 1, 1, 3) == 0, + "8 by 8 path sampling produces exact symmetric diagonal coverage"); + cpu_raster_free(&triangle_result); + display_list_free(&triangle_list); + path_free(&triangle); + + let even_odd: PathData = square_path(PathFillRule.even_odd, 1); + let even_odd_list: DisplayList = path_scene(even_odd, + paint_rgba16(0, 65535, 0, 65535)); + let even_odd_result: CpuRasterResult = cpu_rasterize(even_odd_list, 4, 4); + expect(state, pixel(even_odd_result.surface, 0, 0, 1) == 255 && + pixel(even_odd_result.surface, 2, 2, 3) == 0, + "even-odd fill removes nested contours regardless of matching orientation"); + + let non_zero: PathData = square_path(PathFillRule.non_zero, 1); + let non_zero_list: DisplayList = path_scene(non_zero, + paint_rgba16(0, 0, 65535, 65535)); + let non_zero_result: CpuRasterResult = cpu_rasterize(non_zero_list, 4, 4); + expect(state, pixel(non_zero_result.surface, 2, 2, 2) == 255, + "non-zero fill retains nested contours with matching winding"); + cpu_raster_free(&non_zero_result); + display_list_free(&non_zero_list); + path_free(&non_zero); + cpu_raster_free(&even_odd_result); + display_list_free(&even_odd_list); + path_free(&even_odd); + + let curves: PathData = path_make(PathFillRule.non_zero); + _ = path_move_to(&curves, path_point(0, 3 * unit_scale())); + _ = path_quad_to(&curves, path_point(2 * unit_scale(), 0), + path_point(4 * unit_scale(), 3 * unit_scale())); + _ = path_cubic_to(&curves, path_point(3 * unit_scale(), 4 * unit_scale()), + path_point(unit_scale(), 4 * unit_scale()), path_point(0, 3 * unit_scale())); + _ = path_close(&curves); + _ = path_seal(&curves); + let curves_list: DisplayList = path_scene(curves, + paint_rgba16(40000, 30000, 20000, 65535)); + let first: CpuRasterResult = cpu_rasterize(curves_list, 5, 5); + let second: CpuRasterResult = cpu_rasterize(curves_list, 5, 5); + expect(state, first.error == CpuRasterError.none && first.pixels_touched > 0 && + cpu_surface_hash(first.surface) == cpu_surface_hash(second.surface), + "quadratic and cubic flattening produces stable nonempty CPU output"); + cpu_raster_free(&second); + cpu_raster_free(&first); + display_list_free(&curves_list); + path_free(&curves); +} + +fn path_work_limit_contract(state: *TestState) void { + let path: PathData = path_make(PathFillRule.non_zero); + _ = path_move_to(&path, path_point(0, 0)); + let i: i32 = 0; + while (i < 100) { + let y: i64 = 0; + if (i % 2 != 0) { y = 100 * unit_scale(); } + _ = path_line_to(&path, path_point((i as i64) * unit_scale(), y)); + i = i + 1; + } + _ = path_line_to(&path, path_point(100 * unit_scale(), 100 * unit_scale())); + _ = path_line_to(&path, path_point(0, 100 * unit_scale())); + _ = path_close(&path); + _ = path_seal(&path); + let list: DisplayList = path_scene(path, paint_rgba16(65535, 65535, 65535, 65535)); + let result: CpuRasterResult = cpu_rasterize(list, 100, 100); + expect(state, result.error == CpuRasterError.work_limit && result.error_op == 0 && + result.pixels_touched == 0, + "path raster work exceeding the deterministic budget fails before touching pixels"); + cpu_raster_free(&result); + display_list_free(&list); + path_free(&path); +} + +fn path_clip_transform_contract(state: *TestState) void { + let path: PathData = square_path(PathFillRule.non_zero, 0); + let payload: PathEncodeResult = path_encode(path); + let list: DisplayList = display_list_make(); + _ = display_list_add_resource(&list, + render_resource_spec(12, RenderResourceKind.path, + payload.bytes.data[0..payload.bytes.len], path_resource_format_tag())); + path_encode_free(&payload); + _ = display_list_push(&list, display_op(DisplayOpKind.save, node_key(12))); + let clip: DisplayOp = display_op(DisplayOpKind.clip_rect, node_key(12)); + clip.bounds = rect(2 * unit_scale(), 0, unit_scale(), 3 * unit_scale()); + _ = display_list_push(&list, clip); + let transform: DisplayOp = display_op(DisplayOpKind.concat_transform, node_key(12)); + transform.transform.translate_x = unit_scale(); + _ = display_list_push(&list, transform); + let draw: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(12)); + draw.resource_id = 12; + draw.paint = paint_rgba16(65535, 65535, 0, 65535); + _ = display_list_push(&list, draw); + _ = display_list_push(&list, display_op(DisplayOpKind.restore, node_key(12))); + _ = display_list_seal(&list); + let result: CpuRasterResult = cpu_rasterize(list, 5, 3); + expect(state, result.error == CpuRasterError.none && result.rendered_ops == 5 && + result.pixels_touched == 3, + "path rendering participates in the retained transform and clip stack"); + expect(state, pixel(result.surface, 1, 1, 3) == 0 && + pixel(result.surface, 2, 1, 0) == 255 && pixel(result.surface, 2, 1, 1) == 255 && + pixel(result.surface, 3, 1, 3) == 0, + "transformed path coverage is clipped at subpixel sample positions"); + cpu_raster_free(&result); + display_list_free(&list); + path_free(&path); +} + fn failure_contract(state: *TestState) void { let unsealed: DisplayList = display_list_make(); let unsealed_result: CpuRasterResult = cpu_rasterize(unsealed, 2, 2); @@ -113,17 +256,14 @@ fn failure_contract(state: *TestState) void { display_list_free(&unsealed); let unsupported: DisplayList = display_list_make(); - let path_payload: PathEncodeResult = cpu_path_payload(); - _ = display_list_add_resource(&unsupported, - render_resource_spec(8, RenderResourceKind.path, - path_payload.bytes.data[0..path_payload.bytes.len], path_resource_format_tag())); - path_encode_free(&path_payload); - let path: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(1)); - path.resource_id = 8; - _ = display_list_push(&unsupported, path); + let stroke: DisplayOp = display_op(DisplayOpKind.stroke_rect, node_key(1)); + stroke.bounds = rect(0, 0, unit_scale(), unit_scale()); + _ = display_list_push(&unsupported, stroke); _ = display_list_seal(&unsupported); let unsupported_result: CpuRasterResult = cpu_rasterize(unsupported, 2, 2); - expect(state, unsupported_result.error == CpuRasterError.unsupported_operation && unsupported_result.error_op == 0, "unimplemented path rasterization fails closed at the exact operation"); + expect(state, unsupported_result.error == CpuRasterError.unsupported_operation && + unsupported_result.error_op == 0, + "unimplemented stroke rasterization fails closed at the exact operation"); cpu_raster_free(&unsupported_result); display_list_free(&unsupported); @@ -146,6 +286,9 @@ fn main() i32 { coverage_and_blend_contract(&state); clip_and_transform_contract(&state); deterministic_contract(&state); + path_fill_contract(&state); + path_work_limit_contract(&state); + path_clip_transform_contract(&state); failure_contract(&state); let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); diff --git a/tools/test-headless.sh b/tools/test-headless.sh index f088027..55e4c46 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -53,4 +53,4 @@ cd "$root" "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths, display lists, CPU raster, input, replay, and motion)\n' +printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths, display lists, CPU path raster, input, replay, and motion)\n' From 8d1a7fcebad1a90ec9b58ca89cddbf025f304a47 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 09:30:47 -0700 Subject: [PATCH 026/103] Rasterize centered rectangle strokes --- CHANGELOG.md | 5 +- README.md | 15 +++--- docs/architecture/README.md | 9 ++-- docs/architecture/paths.md | 7 +-- src/render/cpu_raster.zag | 96 +++++++++++++++++++++++++++++++++ src/render/display_list.zag | 3 ++ tests/cpu_raster_contract.zag | 35 ++++++++++-- tests/display_list_contract.zag | 6 +++ tools/test-headless.sh | 2 +- 9 files changed, 157 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5265b88..ec12e30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,8 +51,9 @@ include a generated capability record and exact Zag compiler revision. resource metadata, payload, allocation-policy, and revision serialization. - The first deterministic CPU-oracle subset for fixed-point fills, clipping, axis-aligned transforms, fractional coverage, and source-over alpha, extended - with bounded canonical path fills, quadratic and cubic flattening, non-zero - and even-odd winding, 8 by 8 coverage, and fail-before-pixel work ceilings. + with centered analytic rectangle strokes, bounded canonical path fills, + quadratic and cubic flattening, non-zero and even-odd winding, 8 by 8 + coverage, and fail-before-pixel work ceilings. - Strict executable headless contracts in CI using the exact pinned Zag revision rather than an ambient compiler. - Transformed hit testing and deterministic pointer routing with clipping, diff --git a/README.md b/README.md index b9076aa..c54fcb4 100644 --- a/README.md +++ b/README.md @@ -148,13 +148,14 @@ rasterization, other payload interpretation, damage, complete CPU rasterization, GPU transport, schema evolution, and fuzz coverage remain open. See the [path contract](docs/architecture/paths.md). -The first CPU-oracle subset rasterizes fixed-point rectangle and canonical path -fills with exact clip and axis-aligned transform state, area-based rectangle -edges, deterministic 8 by 8 path coverage, non-zero and even-odd winding, -bounded curve flattening, and source-over alpha into owned RGBA8 surfaces. -Path edge and work budgets fail before pixel mutation. Unsupported images, -glyphs, strokes, skew, layers, and effects fail at the exact operation; this -subset is not yet the complete CPU renderer required by Milestone 2. +The first CPU-oracle subset rasterizes fixed-point rectangle fills, centered +rectangle strokes, and canonical path fills with exact clip and axis-aligned +transform state, analytic rectangle coverage, deterministic 8 by 8 path +coverage, non-zero and even-odd winding, bounded curve flattening, and +source-over alpha into owned RGBA8 surfaces. Path edge and work budgets fail +before pixel mutation. Unsupported images, glyphs, path strokes, skew, layers, +and effects fail at the exact operation; this subset is not yet the complete +CPU renderer required by Milestone 2. The first input slice resolves full affine transforms back to local coordinates, honors local clips and z-order, rejects singular or malformed hit nodes, and diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 3d90eb9..976fbde 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -88,10 +88,11 @@ the stored content identity and revision, and rejects trailing bytes so one scene has one canonical encoding. A bounded variable-length resource section preserves allocation policy, typed metadata, revisions, and exact owned bytes. -The CPU oracle begins with deterministic RGBA8 rectangle and canonical path -fills from 26.6 fixed-point geometry. Clip and positive axis-aligned transform -state, fractional rectangle edges, 8 by 8 path coverage, non-zero and even-odd -winding, fixed curve flattening, and source-over alpha are integer-only. +The CPU oracle begins with deterministic RGBA8 rectangle fills, centered +rectangle strokes, and canonical path fills from 26.6 fixed-point geometry. +Clip and positive axis-aligned transform state, analytic rectangle coverage, +8 by 8 path coverage, non-zero and even-odd winding, fixed curve flattening, +and source-over alpha are integer-only. Explicit edge and work ceilings reject pathological path scenes before pixel mutation. Every unsupported display operation fails at its exact index instead of silently degrading or claiming a visual result. diff --git a/docs/architecture/paths.md b/docs/architecture/paths.md index 69160d2..524d3bc 100644 --- a/docs/architecture/paths.md +++ b/docs/architecture/paths.md @@ -46,6 +46,7 @@ route. Fixed-subdivision flattening is an experimental correctness step, not the final curve-quality contract. Scale-adaptive or analytic curve coverage, exact curve -extrema, strokes, joins, caps, dashes, boolean path operations, and SVG path -syntax remain unavailable until their deterministic, malformed-input, golden, -fidelity, and cleanup suites pass. +extrema, path strokes, joins, caps, dashes, boolean path operations, and SVG +path syntax remain unavailable until their deterministic, malformed-input, +golden, fidelity, and cleanup suites pass. Axis-aligned rectangle strokes are a +separate implemented CPU primitive. diff --git a/src/render/cpu_raster.zag b/src/render/cpu_raster.zag index 16f3787..8bfb89e 100644 --- a/src/render/cpu_raster.zag +++ b/src/render/cpu_raster.zag @@ -446,6 +446,78 @@ fn cpu_fill_rect(surface: *CpuSurface, raw_bounds: Rect, clip: Rect, paint: Pain return touched; } +fn cpu_rect_pixel_coverage(bounds: Rect, x: i32, y: i32) i64 { + if (bounds.width <= 0 || bounds.height <= 0) { return 0; } + let pixel_left: i64 = (x as i64) * unit_scale(); + let pixel_top: i64 = (y as i64) * unit_scale(); + let overlap_left: i64 = bounds.x; + if (pixel_left > overlap_left) { overlap_left = pixel_left; } + let overlap_top: i64 = bounds.y; + if (pixel_top > overlap_top) { overlap_top = pixel_top; } + let overlap_right: i64 = bounds.x + bounds.width; + if (pixel_left + unit_scale() < overlap_right) { + overlap_right = pixel_left + unit_scale(); + } + let overlap_bottom: i64 = bounds.y + bounds.height; + if (pixel_top + unit_scale() < overlap_bottom) { + overlap_bottom = pixel_top + unit_scale(); + } + if (overlap_right <= overlap_left || overlap_bottom <= overlap_top) { return 0; } + return (overlap_right - overlap_left) * (overlap_bottom - overlap_top); +} + +fn cpu_stroke_rect(surface: *CpuSurface, raw_bounds: Rect, clip: Rect, + transform: Transform, paint: Paint) i64 { + let low: i64 = paint.stroke_width / 2; + let high: i64 = paint.stroke_width - low; + let outer_local: Rect = rect( + raw_bounds.x - low, + raw_bounds.y - low, + raw_bounds.width + paint.stroke_width, + raw_bounds.height + paint.stroke_width + ); + let inner_local: Rect = rect( + raw_bounds.x + high, + raw_bounds.y + high, + raw_bounds.width - paint.stroke_width, + raw_bounds.height - paint.stroke_width + ); + let outer: Rect = cpu_rect_intersection( + cpu_rect_intersection(cpu_transform_rect(outer_local, transform), clip), + rect(0, 0, (surface.*.width as i64) * unit_scale(), + (surface.*.height as i64) * unit_scale()) + ); + let inner: Rect = rect(0, 0, 0, 0); + if (inner_local.width > 0 && inner_local.height > 0) { + inner = cpu_rect_intersection( + cpu_rect_intersection(cpu_transform_rect(inner_local, transform), clip), + rect(0, 0, (surface.*.width as i64) * unit_scale(), + (surface.*.height as i64) * unit_scale()) + ); + } + if (outer.width <= 0 || outer.height <= 0) { return 0; } + let first_x: i32 = (outer.x / unit_scale()) as i32; + let first_y: i32 = (outer.y / unit_scale()) as i32; + let last_x: i32 = ((outer.x + outer.width + unit_scale() - 1) / unit_scale()) as i32; + let last_y: i32 = ((outer.y + outer.height + unit_scale() - 1) / unit_scale()) as i32; + let touched: i64 = 0; + let y: i32 = first_y; + while (y < last_y) { + let x: i32 = first_x; + while (x < last_x) { + let coverage: i64 = cpu_rect_pixel_coverage(outer, x, y) - + cpu_rect_pixel_coverage(inner, x, y); + if (coverage > 0) { + cpu_blend_pixel(surface, x, y, paint, coverage); + touched = touched + 1; + } + x = x + 1; + } + y = y + 1; + } + return touched; +} + fn cpu_rasterize(list: DisplayList, width: i32, height: i32) CpuRasterResult { let surface: CpuSurface = cpu_surface_make(width, height); if (surface.width == 0 || surface.height == 0) { @@ -504,6 +576,30 @@ fn cpu_rasterize(list: DisplayList, width: i32, height: i32) CpuRasterResult { return cpu_result(surface, CpuRasterError.invalid_geometry, i, rendered_ops, pixels_touched); } pixels_touched = pixels_touched + cpu_fill_rect(&surface, transformed, current.clip, op.paint); + } else if (op.kind == DisplayOpKind.stroke_rect) { + let low: i64 = op.paint.stroke_width / 2; + let expanded: Rect = rect( + op.bounds.x - low, + op.bounds.y - low, + op.bounds.width + op.paint.stroke_width, + op.bounds.height + op.paint.stroke_width + ); + if (cpu_fixed_safe(expanded.x) == 0 || cpu_fixed_safe(expanded.y) == 0 || + cpu_fixed_safe(expanded.width) == 0 || cpu_fixed_safe(expanded.height) == 0) { + free[CpuRasterState](&stack); + return cpu_result(surface, CpuRasterError.invalid_geometry, + i, rendered_ops, pixels_touched); + } + let transformed: Rect = cpu_transform_rect(expanded, current.transform); + if (cpu_fixed_safe(transformed.x) == 0 || cpu_fixed_safe(transformed.y) == 0 || + cpu_fixed_safe(transformed.width) == 0 || cpu_fixed_safe(transformed.height) == 0) { + free[CpuRasterState](&stack); + return cpu_result(surface, CpuRasterError.invalid_geometry, + i, rendered_ops, pixels_touched); + } + pixels_touched = pixels_touched + cpu_stroke_rect( + &surface, op.bounds, current.clip, current.transform, op.paint + ); } else if (op.kind == DisplayOpKind.draw_path) { let resource_index: i32 = render_resource_find_index(list.resources, op.resource_id); if (resource_index < 0) { diff --git a/src/render/display_list.zag b/src/render/display_list.zag index 0be52f4..cc39a1c 100644 --- a/src/render/display_list.zag +++ b/src/render/display_list.zag @@ -305,6 +305,9 @@ fn display_list_push(list: *DisplayList, op: DisplayOp) DisplayError { op.parameter < 0 || display_fixed_value_valid(op.parameter) == 0) { return display_fail(list, DisplayError.invalid_parameter); } + if (op.kind == DisplayOpKind.stroke_rect && op.paint.stroke_width <= 0) { + return display_fail(list, DisplayError.invalid_parameter); + } if (op.kind == DisplayOpKind.begin_layer && (op.parameter < 0 || op.parameter > 65535)) { return display_fail(list, DisplayError.invalid_parameter); } diff --git a/tests/cpu_raster_contract.zag b/tests/cpu_raster_contract.zag index 8836d22..8e6ff0f 100644 --- a/tests/cpu_raster_contract.zag +++ b/tests/cpu_raster_contract.zag @@ -126,6 +126,30 @@ fn deterministic_contract(state: *TestState) void { display_list_free(&list); } +fn stroke_rect_contract(state: *TestState) void { + let list: DisplayList = display_list_make(); + let stroke: DisplayOp = display_op(DisplayOpKind.stroke_rect, node_key(5)); + stroke.bounds = rect(unit_scale(), unit_scale(), + 4 * unit_scale(), 4 * unit_scale()); + stroke.paint = paint_rgba16(65535, 0, 65535, 65535); + stroke.paint.stroke_width = unit_scale(); + _ = display_list_push(&list, stroke); + _ = display_list_seal(&list); + let result: CpuRasterResult = cpu_rasterize(list, 6, 6); + expect(state, result.error == CpuRasterError.none && result.rendered_ops == 1 && + result.pixels_touched == 32, + "centered rectangle strokes rasterize as bounded analytic rings"); + expect(state, pixel(result.surface, 0, 2, 3) == 128 && + pixel(result.surface, 1, 2, 3) == 128 && + pixel(result.surface, 2, 2, 3) == 0, + "one logical pixel strokes split exact coverage across inner and outer edges"); + expect(state, pixel(result.surface, 0, 0, 3) == 64 && + pixel(result.surface, 1, 1, 3) == 191, + "stroke corners subtract inner area with deterministic rounding"); + cpu_raster_free(&result); + display_list_free(&list); +} + fn path_fill_contract(state: *TestState) void { let triangle: PathData = path_make(PathFillRule.non_zero); _ = path_move_to(&triangle, path_point(0, 0)); @@ -256,14 +280,16 @@ fn failure_contract(state: *TestState) void { display_list_free(&unsealed); let unsupported: DisplayList = display_list_make(); - let stroke: DisplayOp = display_op(DisplayOpKind.stroke_rect, node_key(1)); - stroke.bounds = rect(0, 0, unit_scale(), unit_scale()); - _ = display_list_push(&unsupported, stroke); + let layer: DisplayOp = display_op(DisplayOpKind.begin_layer, node_key(1)); + layer.bounds = rect(0, 0, unit_scale(), unit_scale()); + layer.parameter = 65535; + _ = display_list_push(&unsupported, layer); + _ = display_list_push(&unsupported, display_op(DisplayOpKind.end_layer, node_key(1))); _ = display_list_seal(&unsupported); let unsupported_result: CpuRasterResult = cpu_rasterize(unsupported, 2, 2); expect(state, unsupported_result.error == CpuRasterError.unsupported_operation && unsupported_result.error_op == 0, - "unimplemented stroke rasterization fails closed at the exact operation"); + "unimplemented layer compositing fails closed at the exact operation"); cpu_raster_free(&unsupported_result); display_list_free(&unsupported); @@ -286,6 +312,7 @@ fn main() i32 { coverage_and_blend_contract(&state); clip_and_transform_contract(&state); deterministic_contract(&state); + stroke_rect_contract(&state); path_fill_contract(&state); path_work_limit_contract(&state); path_clip_transform_contract(&state); diff --git a/tests/display_list_contract.zag b/tests/display_list_contract.zag index 9b08a39..a011fbc 100644 --- a/tests/display_list_contract.zag +++ b/tests/display_list_contract.zag @@ -146,6 +146,12 @@ fn rejection_contract(state: *TestState) void { bad_paint.paint.red = 65536; expect(state, display_list_push(&list, bad_paint) == DisplayError.invalid_parameter, "raw paint channels outside RGBA16 fail before mutation"); + let zero_stroke: DisplayOp = display_op(DisplayOpKind.stroke_rect, node_key(1)); + zero_stroke.bounds = rect(0, 0, 10, 10); + zero_stroke.paint.stroke_width = 0; + expect(state, display_list_push(&list, zero_stroke) == DisplayError.invalid_parameter, + "rectangle strokes require a positive logical width"); + _ = display_list_push(&list, display_op(DisplayOpKind.save, node_key(1))); expect(state, display_list_seal(&list) == DisplayError.unbalanced_stack && list.sealed == 0, "unbalanced save state cannot seal"); _ = display_list_push(&list, display_op(DisplayOpKind.restore, node_key(1))); diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 55e4c46..59ba02d 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -53,4 +53,4 @@ cd "$root" "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths, display lists, CPU path raster, input, replay, and motion)\n' +printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths, display lists, CPU shape raster, input, replay, and motion)\n' From b6f8a9144f54c91ca4c69e63e19b75172b7d41a6 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 09:38:24 -0700 Subject: [PATCH 027/103] Render canonical decoded images --- CHANGELOG.md | 3 + README.md | 19 +-- ROADMAP.md | 2 +- docs/architecture/README.md | 14 +- docs/architecture/images.md | 36 +++++ docs/architecture/render-resources.md | 7 +- src/render/cpu_raster.zag | 193 ++++++++++++++++++++++++++ src/render/display_list.zag | 9 ++ src/render/image.zag | 40 ++++++ src/replay/replay.zag | 28 +++- tests/cpu_raster_contract.zag | 90 ++++++++++++ tests/display_list_contract.zag | 22 ++- tests/image_contract.zag | 92 ++++++++++++ tools/test-headless.sh | 4 +- 14 files changed, 539 insertions(+), 20 deletions(-) create mode 100644 docs/architecture/images.md create mode 100644 src/render/image.zag create mode 100644 tests/image_contract.zag diff --git a/CHANGELOG.md b/CHANGELOG.md index ec12e30..7c2ccaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,9 @@ include a generated capability record and exact Zag compiler revision. even-odd fill rules, line and curve commands, immutable identity, typed display-resource validation, malformed-input offsets, and byte-identical round trips. +- Canonical decoded RGBA8 image resources with overflow-safe dimensional + validation, exact payload sizing, explicit color-space truth, deterministic + premultiplied bilinear CPU sampling, fractional-edge coverage, and opacity. - An experimental immutable display list for explicit paints, resources, clips, transforms, layers, and effects with deterministic content identity. - A versioned canonical display-list codec with bounded validated decoding and diff --git a/README.md b/README.md index c54fcb4..72e7b6f 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ is normative in [DEPENDENCIES.md](DEPENDENCIES.md). | Product and architecture contract | accepted | [RFC index](docs/rfcs/README.md) | | Compiler dependency | pinned, prerequisites incomplete | [toolchain lock](contracts/toolchain.json) | | Platform shells | unavailable | [support matrix](SUPPORT.md) | -| Headless core | experimental state, reconciliation, intrinsic measurement, geometry, Flex, Grid, Overlay, scroll, virtual collections, collection semantics, Talkback, canonical paths, display lists, CPU raster, input, replay, and motion | [headless test](tools/test-headless.sh) | +| Headless core | experimental state, reconciliation, intrinsic measurement, geometry, Flex, Grid, Overlay, scroll, virtual collections, collection semantics, Talkback, canonical paths and images, display lists, CPU raster, input, replay, and motion | [headless test](tools/test-headless.sh) | | Components and visual language | inventory only, visual review pending | [component inventory](contracts/components.json) | | Flex and Zagkit Talkback | Flex foundation and in-process ID-first Talkback dispatch executing; native transport remains unavailable | [Talkback contract](docs/automation/talkback.md) | | Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | @@ -143,19 +143,22 @@ geometry, identity, truncation, and trailing bytes are validated before use. The version 2 binary codec round-trips resource metadata, payloads, revisions, allocation policy, and operations byte-identically and rejects malformed, truncated, -noncanonical, unknown-version, and hash-mismatched input. Path flattening and -rasterization, other payload interpretation, damage, complete CPU rasterization, -GPU transport, schema evolution, and fuzz coverage remain open. See the -[path contract](docs/architecture/paths.md). +noncanonical, unknown-version, and hash-mismatched input. Scale-adaptive path +coverage, encoded image and glyph interpretation, damage, complete CPU +rasterization, GPU transport, schema evolution, and fuzz coverage remain open. +See the [path contract](docs/architecture/paths.md) and +[image contract](docs/architecture/images.md). The first CPU-oracle subset rasterizes fixed-point rectangle fills, centered rectangle strokes, and canonical path fills with exact clip and axis-aligned transform state, analytic rectangle coverage, deterministic 8 by 8 path coverage, non-zero and even-odd winding, bounded curve flattening, and source-over alpha into owned RGBA8 surfaces. Path edge and work budgets fail -before pixel mutation. Unsupported images, glyphs, path strokes, skew, layers, -and effects fail at the exact operation; this subset is not yet the complete -CPU renderer required by Milestone 2. +before pixel mutation. Canonical decoded sRGB RGBA8 images render with exact +one-to-one texels, premultiplied bilinear scaling, fractional-edge coverage, +clip and transform state, and operation opacity. Unsupported color conversion, +glyphs, path strokes, skew, layers, and effects fail at the exact operation; +this subset is not yet the complete CPU renderer required by Milestone 2. The first input slice resolves full affine transforms back to local coordinates, honors local clips and z-order, rejects singular or malformed hit nodes, and diff --git a/ROADMAP.md b/ROADMAP.md index cda18c9..984ff0f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,7 +41,7 @@ affected Zagkit conformance. Strict native Zag contracts currently execute state and dependency tracking, keyed reconciliation, bounded geometry, retained intrinsic measurement, adaptive and wrapped Flex, semantics, ID-first in-process Talkback dispatch, -immutable display lists and their codec, a rectangle and path CPU-oracle subset, +immutable display lists and their codec, a shape and image CPU-oracle subset, transformed pointer routing, deterministic replay, and motion kernels. This is real headless execution but does not satisfy the milestone exit gate yet. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 976fbde..b4277b0 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -32,6 +32,7 @@ Detailed experimental contracts: - [Flex placement and adaptive spacing](flex.md) - [motion scheduler and tracks](motion.md) - [canonical vector paths](paths.md) +- [canonical decoded images](images.md) The first experimental compiled slice now fixes the initial Zag shapes for `NodeKey`, `State`, `Binding`, `Action`, `Environment`, `ViewContext`, @@ -79,7 +80,9 @@ enforces configurable byte and count ceilings, requires exact replacement revisions, and verifies sealed byte-level identity. Display lists own the store. Path resources additionally require the bounded canonical [ZKPATH01 contract](paths.md) and are validated once before operation references -seal. SVG, PNG, font, image, and glyph decoding remain unavailable. +seal. Canonical decoded [RGBA8 images](images.md) also have exact dimensional, +schema, color-space, and payload-size validation. SVG, PNG, font, and glyph +decoding remain unavailable. Display-list replay uses the versioned little-endian `ZKDL` version 2 codec. Decoding is bounded to one million operations, reconstructs operations through the @@ -89,10 +92,11 @@ scene has one canonical encoding. A bounded variable-length resource section preserves allocation policy, typed metadata, revisions, and exact owned bytes. The CPU oracle begins with deterministic RGBA8 rectangle fills, centered -rectangle strokes, and canonical path fills from 26.6 fixed-point geometry. -Clip and positive axis-aligned transform state, analytic rectangle coverage, -8 by 8 path coverage, non-zero and even-odd winding, fixed curve flattening, -and source-over alpha are integer-only. +rectangle strokes, canonical path fills, and canonical decoded image draws from +26.6 fixed-point geometry. Clip and positive axis-aligned transform state, +analytic rectangle coverage, 8 by 8 path coverage, non-zero and even-odd +winding, fixed curve flattening, premultiplied bilinear image sampling, and +source-over alpha are integer-only. Explicit edge and work ceilings reject pathological path scenes before pixel mutation. Every unsupported display operation fails at its exact index instead of silently degrading or claiming a visual result. diff --git a/docs/architecture/images.md b/docs/architecture/images.md new file mode 100644 index 0000000..c5c9d70 --- /dev/null +++ b/docs/architecture/images.md @@ -0,0 +1,36 @@ +# Canonical decoded images + +Status: experimental headless primitive + +Zagkit separates encoded assets from renderer-ready pixels. Canonical decoded +image resources use format tag 1, explicit positive width and height, explicit +color-space metadata, and exactly `width * height * 4` owned RGBA8 bytes in +row-major order. Channels use straight alpha at the ownership boundary. + +Dimension and byte-count arithmetic is overflow checked before allocation or +indexing. Unknown schemas, absent color-space truth, mismatched payload size, +zero dimensions, and dimensions above the shared hard limits fail during +display-list resource validation, before draw operations seal. + +## CPU sampling + +The current CPU oracle renders canonical sRGB images into positive +axis-aligned destination rectangles. It maps destination and source pixel +centers, performs deterministic 8-bit bilinear interpolation in premultiplied +form to avoid transparent-edge color fringes, then converts back to straight +color for source-over composition. A 4 by 4 coverage grid handles fractional +destination edges and clips separately from texture filtering, so one-to-one +integer placement preserves exact texels. Operation alpha is the only paint +modulation; unused paint channels must remain canonical. + +Transformed destination geometry and pixel-sample work have explicit ceilings. +Unsupported color-space conversion fails at the exact draw operation before +pixels are touched. The deterministic replay scene exercises the owned image, +display-list, and CPU route. + +This is not PNG support yet. PNG parsing, decompression, filters, palettes, +grayscale expansion, profiles, bomb limits, and malformed-stream handling must +produce this canonical image form through their own verified decoder. Linear +light filtering, Display P3 conversion, wide-gamut surfaces, mipmapping, +high-quality downsampling, image tiling, and GPU upload caches also remain +unavailable. diff --git a/docs/architecture/render-resources.md b/docs/architecture/render-resources.md index 45d626a..96c74fb 100644 --- a/docs/architecture/render-resources.md +++ b/docs/architecture/render-resources.md @@ -7,7 +7,8 @@ feed Zagkit paths, decoded images, glyph runs, effects, fonts, SVG, and PNG. It is not itself a decoder and does not make an opaque payload renderable. SVG parsing, PNG parsing and color management, font validation, glyph shaping, and decoded rendering remain separate open contracts. Canonical path payloads -are the first typed schema layered over this store; see [paths](paths.md). +and decoded RGBA8 images are the first typed schemas layered over this store; +see [paths](paths.md) and [images](images.md). ## Identity and metadata @@ -53,7 +54,7 @@ canonical resource section before operations and reconstructs it through the same bounded validation path; decoding and re-encoding are byte identical. The current executable contract still does not decode SVG or PNG, validate font -tables, interpret glyph payload schemas, rasterize path strokes or images, -cache platform uploads, or implement memory-pressure eviction. Those +tables, interpret glyph payload schemas, rasterize path strokes, convert wide +gamut images, cache platform uploads, or implement memory-pressure eviction. Those capabilities remain unavailable until their own malformed-input, rendering, replacement, and cleanup suites pass. diff --git a/src/render/cpu_raster.zag b/src/render/cpu_raster.zag index 8bfb89e..b6ab9cb 100644 --- a/src/render/cpu_raster.zag +++ b/src/render/cpu_raster.zag @@ -41,6 +41,13 @@ struct CpuPathBuildResult { error: CpuRasterError, } +struct CpuImageSample { + red_premultiplied: i64, + green_premultiplied: i64, + blue_premultiplied: i64, + alpha: i64, +} + fn cpu_surface_make(width: i32, height: i32) CpuSurface { let pixels: ArrayList[u8] = make[u8](1); if (width <= 0 || height <= 0 || width > 8192 || height > 8192) { @@ -518,6 +525,168 @@ fn cpu_stroke_rect(surface: *CpuSurface, raw_bounds: Rect, clip: Rect, return touched; } +fn cpu_image_texel(resource: RenderResource, x: i32, y: i32, + channel: i32) i64 { + let index: i32 = ((y * resource.width as i32 + x) * 4) + channel; + return resource.payload.data[index] as i64; +} + +fn cpu_image_bilinear(value_00: i64, value_10: i64, + value_01: i64, value_11: i64, fraction_x: i64, fraction_y: i64) i64 { + let inverse_x: i64 = 256 - fraction_x; + let inverse_y: i64 = 256 - fraction_y; + let top: i64 = value_00 * inverse_x + value_10 * fraction_x; + let bottom: i64 = value_01 * inverse_x + value_11 * fraction_x; + return (top * inverse_y + bottom * fraction_y + 32768) / 65536; +} + +fn cpu_image_sample(resource: RenderResource, source_x: i64, + source_y: i64) CpuImageSample { + let max_x: i64 = (resource.width - 1) * 256; + let max_y: i64 = (resource.height - 1) * 256; + let clamped_x: i64 = source_x; + let clamped_y: i64 = source_y; + if (clamped_x < 0) { clamped_x = 0; } + if (clamped_y < 0) { clamped_y = 0; } + if (clamped_x > max_x) { clamped_x = max_x; } + if (clamped_y > max_y) { clamped_y = max_y; } + let x0: i32 = (clamped_x / 256) as i32; + let y0: i32 = (clamped_y / 256) as i32; + let x1: i32 = x0; + let y1: i32 = y0; + if (x0 + 1 < resource.width as i32) { x1 = x0 + 1; } + if (y0 + 1 < resource.height as i32) { y1 = y0 + 1; } + let fraction_x: i64 = clamped_x % 256; + let fraction_y: i64 = clamped_y % 256; + let alpha_00: i64 = cpu_image_texel(resource, x0, y0, 3); + let alpha_10: i64 = cpu_image_texel(resource, x1, y0, 3); + let alpha_01: i64 = cpu_image_texel(resource, x0, y1, 3); + let alpha_11: i64 = cpu_image_texel(resource, x1, y1, 3); + return CpuImageSample{ + .red_premultiplied = cpu_image_bilinear( + cpu_image_texel(resource, x0, y0, 0) * alpha_00, + cpu_image_texel(resource, x1, y0, 0) * alpha_10, + cpu_image_texel(resource, x0, y1, 0) * alpha_01, + cpu_image_texel(resource, x1, y1, 0) * alpha_11, + fraction_x, fraction_y + ), + .green_premultiplied = cpu_image_bilinear( + cpu_image_texel(resource, x0, y0, 1) * alpha_00, + cpu_image_texel(resource, x1, y0, 1) * alpha_10, + cpu_image_texel(resource, x0, y1, 1) * alpha_01, + cpu_image_texel(resource, x1, y1, 1) * alpha_11, + fraction_x, fraction_y + ), + .blue_premultiplied = cpu_image_bilinear( + cpu_image_texel(resource, x0, y0, 2) * alpha_00, + cpu_image_texel(resource, x1, y0, 2) * alpha_10, + cpu_image_texel(resource, x0, y1, 2) * alpha_01, + cpu_image_texel(resource, x1, y1, 2) * alpha_11, + fraction_x, fraction_y + ), + .alpha = cpu_image_bilinear(alpha_00, alpha_10, alpha_01, alpha_11, + fraction_x, fraction_y), + }; +} + +fn cpu_draw_image(surface: *CpuSurface, resource: RenderResource, + destination: Rect, clip: Rect, opacity: i64) CpuRasterResult { + if (resource.color_space != RenderColorSpace.srgb) { + return cpu_result(surface.*, CpuRasterError.unsupported_operation, + 0 - 1, 0, 0); + } + let coordinate_limit: i64 = cpu_path_coordinate_limit(); + if (destination.x < 0 - coordinate_limit || destination.x > coordinate_limit || + destination.y < 0 - coordinate_limit || destination.y > coordinate_limit || + destination.width <= 0 || destination.width > coordinate_limit || + destination.height <= 0 || destination.height > coordinate_limit) { + return cpu_result(surface.*, CpuRasterError.invalid_geometry, 0 - 1, 0, 0); + } + let surface_bounds: Rect = rect(0, 0, (surface.*.width as i64) * unit_scale(), + (surface.*.height as i64) * unit_scale()); + let bounds: Rect = cpu_rect_intersection( + cpu_rect_intersection(destination, clip), surface_bounds + ); + if (bounds.width <= 0 || bounds.height <= 0) { + return cpu_result(surface.*, CpuRasterError.none, 0 - 1, 0, 0); + } + let first_x: i32 = (bounds.x / unit_scale()) as i32; + let first_y: i32 = (bounds.y / unit_scale()) as i32; + let last_x: i32 = ((bounds.x + bounds.width + unit_scale() - 1) / unit_scale()) as i32; + let last_y: i32 = ((bounds.y + bounds.height + unit_scale() - 1) / unit_scale()) as i32; + let pixel_count: i64 = ((last_x - first_x) as i64) * ((last_y - first_y) as i64); + let sample_axis: i32 = 4; + let samples: i64 = (sample_axis as i64) * (sample_axis as i64); + if (pixel_count <= 0 || pixel_count > 50000000 / samples) { + return cpu_result(surface.*, CpuRasterError.work_limit, 0 - 1, 0, 0); + } + let touched: i64 = 0; + let y: i32 = first_y; + while (y < last_y) { + let x: i32 = first_x; + while (x < last_x) { + let inside: i64 = 0; + let sample_y: i32 = 0; + while (sample_y < sample_axis) { + let point_y: i64 = (y as i64) * unit_scale() + + ((sample_y as i64) * unit_scale() + unit_scale() / 2) / + (sample_axis as i64); + let sample_x: i32 = 0; + while (sample_x < sample_axis) { + let point_x: i64 = (x as i64) * unit_scale() + + ((sample_x as i64) * unit_scale() + unit_scale() / 2) / + (sample_axis as i64); + if (point_x >= destination.x && point_x < destination.x + destination.width && + point_y >= destination.y && point_y < destination.y + destination.height && + point_x >= clip.x && point_x < clip.x + clip.width && + point_y >= clip.y && point_y < clip.y + clip.height) { + inside = inside + 1; + } + sample_x = sample_x + 1; + } + sample_y = sample_y + 1; + } + if (inside > 0) { + let center_x: i64 = (x as i64) * unit_scale() + unit_scale() / 2; + let center_y: i64 = (y as i64) * unit_scale() + unit_scale() / 2; + if (center_x < destination.x) { center_x = destination.x; } + if (center_y < destination.y) { center_y = destination.y; } + if (center_x >= destination.x + destination.width) { + center_x = destination.x + destination.width - 1; + } + if (center_y >= destination.y + destination.height) { + center_y = destination.y + destination.height - 1; + } + let source_x: i64 = ((center_x - destination.x) * + (resource.width * 256)) / destination.width - 128; + let source_y: i64 = ((center_y - destination.y) * + (resource.height * 256)) / destination.height - 128; + let sample: CpuImageSample = cpu_image_sample(resource, source_x, source_y); + let alpha: i64 = (sample.alpha * inside + samples / 2) / samples; + if (alpha > 0 && sample.alpha > 0) { + let output_alpha: i64 = (alpha * opacity + 32767) / 65535; + let paint: Paint = paint_rgba16( + ((sample.red_premultiplied + sample.alpha / 2) / + sample.alpha) * 257, + ((sample.green_premultiplied + sample.alpha / 2) / + sample.alpha) * 257, + ((sample.blue_premultiplied + sample.alpha / 2) / + sample.alpha) * 257, + output_alpha * 257 + ); + if (output_alpha > 0) { + cpu_blend_pixel(surface, x, y, paint, 4096); + touched = touched + 1; + } + } + } + x = x + 1; + } + y = y + 1; + } + return cpu_result(surface.*, CpuRasterError.none, 0 - 1, 0, touched); +} + fn cpu_rasterize(list: DisplayList, width: i32, height: i32) CpuRasterResult { let surface: CpuSurface = cpu_surface_make(width, height); if (surface.width == 0 || surface.height == 0) { @@ -600,6 +769,30 @@ fn cpu_rasterize(list: DisplayList, width: i32, height: i32) CpuRasterResult { pixels_touched = pixels_touched + cpu_stroke_rect( &surface, op.bounds, current.clip, current.transform, op.paint ); + } else if (op.kind == DisplayOpKind.draw_image) { + let resource_index: i32 = render_resource_find_index(list.resources, op.resource_id); + if (resource_index < 0) { + free[CpuRasterState](&stack); + return cpu_result(surface, CpuRasterError.mutated_display_list, + i, rendered_ops, pixels_touched); + } + let transformed: Rect = cpu_transform_rect(op.bounds, current.transform); + if (cpu_fixed_safe(transformed.x) == 0 || cpu_fixed_safe(transformed.y) == 0 || + cpu_fixed_safe(transformed.width) == 0 || cpu_fixed_safe(transformed.height) == 0) { + free[CpuRasterState](&stack); + return cpu_result(surface, CpuRasterError.invalid_geometry, + i, rendered_ops, pixels_touched); + } + let image_result: CpuRasterResult = cpu_draw_image( + &surface, list.resources.resources.data[resource_index], + transformed, current.clip, op.paint.alpha + ); + if (image_result.error != CpuRasterError.none) { + free[CpuRasterState](&stack); + return cpu_result(surface, image_result.error, i, + rendered_ops, pixels_touched); + } + pixels_touched = pixels_touched + image_result.pixels_touched; } else if (op.kind == DisplayOpKind.draw_path) { let resource_index: i32 = render_resource_find_index(list.resources, op.resource_id); if (resource_index < 0) { diff --git a/src/render/display_list.zag b/src/render/display_list.zag index cc39a1c..7bd480b 100644 --- a/src/render/display_list.zag +++ b/src/render/display_list.zag @@ -3,6 +3,7 @@ @import("../core/view_contract.zag") @import("resources.zag") @import("path.zag") +@import("image.zag") enum DisplayOpKind { save, @@ -272,6 +273,9 @@ fn display_resource_payload_valid(resource: RenderResource) i32 { return (resource.format_tag == path_resource_format_tag() && path_payload_valid(resource.payload.data[0..resource.payload.len]) != 0) as i32; } + if (resource.kind == RenderResourceKind.image) { + return (image_resource_validate(resource) == ImagePayloadError.none) as i32; + } return 1; } @@ -308,6 +312,11 @@ fn display_list_push(list: *DisplayList, op: DisplayOp) DisplayError { if (op.kind == DisplayOpKind.stroke_rect && op.paint.stroke_width <= 0) { return display_fail(list, DisplayError.invalid_parameter); } + if (op.kind == DisplayOpKind.draw_image && + (op.paint.red != 0 || op.paint.green != 0 || op.paint.blue != 0 || + op.paint.stroke_width != unit_scale())) { + return display_fail(list, DisplayError.invalid_parameter); + } if (op.kind == DisplayOpKind.begin_layer && (op.parameter < 0 || op.parameter > 65535)) { return display_fail(list, DisplayError.invalid_parameter); } diff --git a/src/render/image.zag b/src/render/image.zag new file mode 100644 index 0000000..e81d192 --- /dev/null +++ b/src/render/image.zag @@ -0,0 +1,40 @@ +@import("resources.zag") + +enum ImagePayloadError { + none, + wrong_kind, + unsupported_format, + invalid_dimensions, + invalid_color_space, + size_mismatch, +} + +fn image_resource_format_tag() i64 { return 1; } + +fn image_payload_expected_bytes(width: i64, height: i64) i64 { + if (width <= 0 || height <= 0 || width > 1048576 || height > 1048576) { + return 0; + } + if (width > 536870912 / height) { return 0; } + let pixels: i64 = width * height; + if (pixels > 536870912 / 4) { return 0; } + return pixels * 4; +} + +fn image_resource_validate(resource: RenderResource) ImagePayloadError { + if (resource.kind != RenderResourceKind.image) { + return ImagePayloadError.wrong_kind; + } + if (resource.format_tag != image_resource_format_tag()) { + return ImagePayloadError.unsupported_format; + } + let expected: i64 = image_payload_expected_bytes(resource.width, resource.height); + if (expected <= 0) { return ImagePayloadError.invalid_dimensions; } + if (resource.color_space == RenderColorSpace.none) { + return ImagePayloadError.invalid_color_space; + } + if (resource.payload.len as i64 != expected) { + return ImagePayloadError.size_mismatch; + } + return ImagePayloadError.none; +} diff --git a/src/replay/replay.zag b/src/replay/replay.zag index 0e25a1b..e82599c 100644 --- a/src/replay/replay.zag +++ b/src/replay/replay.zag @@ -642,6 +642,22 @@ fn replay_build_scene(state_value: i64, backend_generation: i64) ReplayResult { semantics_tree_free(&semantics); return replay_result_error(ReplayError.scene_error, 0 - 1, 0); } + let replay_image_payload: []u8 = "RGBA"; + if (magnitude != 0) { replay_image_payload = "ZKUI"; } + let replay_image_spec: RenderResourceSpec = render_resource_spec( + 902, RenderResourceKind.image, replay_image_payload, + image_resource_format_tag() + ); + replay_image_spec.width = 1; + replay_image_spec.height = 1; + replay_image_spec.color_space = RenderColorSpace.srgb; + if (display_list_add_resource(&list, replay_image_spec) != + RenderResourceError.none) { + display_list_free(&list); + flex_wrap_free(&layout); + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } let first_op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(101)); first_op.bounds = layout.placements.data[0].frame; let blue: i64 = (backend_generation % 8) * 4096; @@ -673,7 +689,17 @@ fn replay_build_scene(state_value: i64, backend_generation: i64) ReplayResult { let path_op: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(104)); path_op.resource_id = 901; path_op.paint = paint_rgba16(57344, 49152, 8192, 32768); - if (display_list_push(&list, path_op) != DisplayError.none || + if (display_list_push(&list, path_op) != DisplayError.none) { + display_list_free(&list); + flex_wrap_free(&layout); + semantics_tree_free(&semantics); + return replay_result_error(ReplayError.scene_error, 0 - 1, 0); + } + let image_op: DisplayOp = display_op(DisplayOpKind.draw_image, node_key(105)); + image_op.resource_id = 902; + image_op.bounds = rect(5 * unit_scale(), 0, 2 * unit_scale(), 2 * unit_scale()); + image_op.paint.alpha = 50000; + if (display_list_push(&list, image_op) != DisplayError.none || display_list_seal(&list) != DisplayError.none) { display_list_free(&list); flex_wrap_free(&layout); diff --git a/tests/cpu_raster_contract.zag b/tests/cpu_raster_contract.zag index 8e6ff0f..292b041 100644 --- a/tests/cpu_raster_contract.zag +++ b/tests/cpu_raster_contract.zag @@ -31,6 +31,34 @@ fn path_scene(path: PathData, paint: Paint) DisplayList { return list; } +fn image_scene(pixels: []u8, width: i64, height: i64, + bounds: Rect, color_space: RenderColorSpace, opacity: i64) DisplayList { + let list: DisplayList = display_list_make(); + let spec: RenderResourceSpec = render_resource_spec( + 18, RenderResourceKind.image, pixels, image_resource_format_tag() + ); + spec.width = width; + spec.height = height; + spec.color_space = color_space; + _ = display_list_add_resource(&list, spec); + let draw: DisplayOp = display_op(DisplayOpKind.draw_image, node_key(18)); + draw.resource_id = 18; + draw.bounds = bounds; + draw.paint.alpha = opacity; + _ = display_list_push(&list, draw); + _ = display_list_seal(&list); + return list; +} + +fn rgba_pixels_2x2() ArrayList[u8] { + let pixels: ArrayList[u8] = make[u8](16); + push[u8](&pixels, 255); push[u8](&pixels, 0); push[u8](&pixels, 0); push[u8](&pixels, 255); + push[u8](&pixels, 0); push[u8](&pixels, 255); push[u8](&pixels, 0); push[u8](&pixels, 255); + push[u8](&pixels, 0); push[u8](&pixels, 0); push[u8](&pixels, 255); push[u8](&pixels, 255); + push[u8](&pixels, 255); push[u8](&pixels, 255); push[u8](&pixels, 255); push[u8](&pixels, 0); + return pixels; +} + fn square_path(fill_rule: PathFillRule, include_inner: i32) PathData { let path: PathData = path_make(fill_rule); _ = path_move_to(&path, path_point(0, 0)); @@ -150,6 +178,67 @@ fn stroke_rect_contract(state: *TestState) void { display_list_free(&list); } +fn image_contract(state: *TestState) void { + let pixels: ArrayList[u8] = rgba_pixels_2x2(); + let exact: DisplayList = image_scene(pixels.data[0..pixels.len], 2, 2, + rect(0, 0, 2 * unit_scale(), 2 * unit_scale()), + RenderColorSpace.srgb, 65535); + let exact_result: CpuRasterResult = cpu_rasterize(exact, 2, 2); + expect(state, exact_result.error == CpuRasterError.none && + exact_result.rendered_ops == 1 && exact_result.pixels_touched == 3, + "canonical decoded images render through the explicit draw-image operation"); + expect(state, pixel(exact_result.surface, 0, 0, 0) == 255 && + pixel(exact_result.surface, 1, 0, 1) == 255 && + pixel(exact_result.surface, 0, 1, 2) == 255 && + pixel(exact_result.surface, 1, 1, 3) == 0, + "one-to-one image rendering preserves exact RGBA8 texels including transparency"); + cpu_raster_free(&exact_result); + display_list_free(&exact); + free[u8](&pixels); + + let gradient: ArrayList[u8] = make[u8](8); + push[u8](&gradient, 255); push[u8](&gradient, 0); push[u8](&gradient, 0); push[u8](&gradient, 255); + push[u8](&gradient, 0); push[u8](&gradient, 0); push[u8](&gradient, 255); push[u8](&gradient, 255); + let scaled: DisplayList = image_scene(gradient.data[0..gradient.len], 2, 1, + rect(0, 0, 4 * unit_scale(), unit_scale()), RenderColorSpace.srgb, 65535); + let scaled_result: CpuRasterResult = cpu_rasterize(scaled, 4, 1); + expect(state, pixel(scaled_result.surface, 0, 0, 0) == 255 && + pixel(scaled_result.surface, 0, 0, 2) == 0 && + pixel(scaled_result.surface, 1, 0, 0) == 191 && + pixel(scaled_result.surface, 1, 0, 2) == 64 && + pixel(scaled_result.surface, 2, 0, 0) == 64 && + pixel(scaled_result.surface, 2, 0, 2) == 191 && + pixel(scaled_result.surface, 3, 0, 2) == 255, + "bilinear scaling is symmetric and aligns source and destination pixel centers"); + cpu_raster_free(&scaled_result); + display_list_free(&scaled); + free[u8](&gradient); + + let red: ArrayList[u8] = make[u8](4); + push[u8](&red, 255); push[u8](&red, 0); push[u8](&red, 0); push[u8](&red, 255); + let translucent: DisplayList = image_scene(red.data[0..red.len], 1, 1, + rect(unit_scale() / 2, 0, unit_scale(), unit_scale()), + RenderColorSpace.srgb, 32768); + let translucent_result: CpuRasterResult = cpu_rasterize(translucent, 2, 1); + expect(state, pixel(translucent_result.surface, 0, 0, 0) == 255 && + pixel(translucent_result.surface, 1, 0, 0) == 255 && + pixel(translucent_result.surface, 0, 0, 3) == 64 && + pixel(translucent_result.surface, 1, 0, 3) == 64, + "fractional image edges and operation opacity compose without dark fringes"); + cpu_raster_free(&translucent_result); + display_list_free(&translucent); + + let unsupported: DisplayList = image_scene(red.data[0..red.len], 1, 1, + rect(0, 0, unit_scale(), unit_scale()), RenderColorSpace.display_p3, 65535); + let unsupported_result: CpuRasterResult = cpu_rasterize(unsupported, 1, 1); + expect(state, unsupported_result.error == CpuRasterError.unsupported_operation && + unsupported_result.error_op == 0 && unsupported_result.pixels_touched == 0, + "unimplemented color conversion fails before image pixels are touched"); + cpu_raster_free(&unsupported_result); + display_list_free(&unsupported); + free[u8](&red); +} + fn path_fill_contract(state: *TestState) void { let triangle: PathData = path_make(PathFillRule.non_zero); _ = path_move_to(&triangle, path_point(0, 0)); @@ -313,6 +402,7 @@ fn main() i32 { clip_and_transform_contract(&state); deterministic_contract(&state); stroke_rect_contract(&state); + image_contract(&state); path_fill_contract(&state); path_work_limit_contract(&state); path_clip_transform_contract(&state); diff --git a/tests/display_list_contract.zag b/tests/display_list_contract.zag index a011fbc..646cc9f 100644 --- a/tests/display_list_contract.zag +++ b/tests/display_list_contract.zag @@ -33,7 +33,7 @@ fn build_scene(color: i64) DisplayList { path_payload.bytes.data[0..path_payload.bytes.len], path_resource_format_tag())); path_encode_free(&path_payload); let image_resource: RenderResourceSpec = render_resource_spec( - 202, RenderResourceKind.image, "RGBA", 2 + 202, RenderResourceKind.image, "RGBA", image_resource_format_tag() ); image_resource.width = 1; image_resource.height = 1; image_resource.color_space = RenderColorSpace.srgb; @@ -152,6 +152,13 @@ fn rejection_contract(state: *TestState) void { expect(state, display_list_push(&list, zero_stroke) == DisplayError.invalid_parameter, "rectangle strokes require a positive logical width"); + let tinted_image: DisplayOp = display_op(DisplayOpKind.draw_image, node_key(1)); + tinted_image.bounds = rect(0, 0, 10, 10); + tinted_image.resource_id = 1; + tinted_image.paint.red = 1; + expect(state, display_list_push(&list, tinted_image) == DisplayError.invalid_parameter, + "draw-image paint keeps RGB and stroke fields canonical while alpha owns opacity"); + _ = display_list_push(&list, display_op(DisplayOpKind.save, node_key(1))); expect(state, display_list_seal(&list) == DisplayError.unbalanced_stack && list.sealed == 0, "unbalanced save state cannot seal"); _ = display_list_push(&list, display_op(DisplayOpKind.restore, node_key(1))); @@ -188,6 +195,19 @@ fn rejection_contract(state: *TestState) void { malformed.error_index == 0 - 1, "canonical path validation rejects opaque owned bytes before operation validation"); display_list_free(&malformed); + + let malformed_image: DisplayList = display_list_make(); + let malformed_image_spec: RenderResourceSpec = render_resource_spec( + 7, RenderResourceKind.image, "RGB", image_resource_format_tag() + ); + malformed_image_spec.width = 1; + malformed_image_spec.height = 1; + malformed_image_spec.color_space = RenderColorSpace.srgb; + _ = display_list_add_resource(&malformed_image, malformed_image_spec); + expect(state, display_list_seal(&malformed_image) == DisplayError.invalid_resource && + malformed_image.error_index == 0 - 1, + "decoded image byte counts are validated before operation references"); + display_list_free(&malformed_image); } fn deterministic_sweep(state: *TestState) void { diff --git a/tests/image_contract.zag b/tests/image_contract.zag new file mode 100644 index 0000000..51bbd84 --- /dev/null +++ b/tests/image_contract.zag @@ -0,0 +1,92 @@ +@import("../src/render/image.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn test_resource(kind: RenderResourceKind, payload: []u8, format_tag: i64, + width: i64, height: i64, color_space: RenderColorSpace) RenderResource { + let spec: RenderResourceSpec = render_resource_spec(1, kind, payload, format_tag); + spec.width = width; + spec.height = height; + spec.color_space = color_space; + return render_resource_from_spec(spec); +} + +fn resource_free(resource: *RenderResource) void { + free[u8](&resource.*.payload); +} + +fn validation_contract(state: *TestState) void { + let valid: RenderResource = test_resource(RenderResourceKind.image, + "RGBA", image_resource_format_tag(), 1, 1, RenderColorSpace.srgb); + expect(state, image_resource_validate(valid) == ImagePayloadError.none, + "one RGBA8 texel with explicit sRGB metadata is canonical"); + resource_free(&valid); + + let wrong_kind: RenderResource = test_resource(RenderResourceKind.path, + "RGBA", image_resource_format_tag(), 1, 1, RenderColorSpace.srgb); + expect(state, image_resource_validate(wrong_kind) == ImagePayloadError.wrong_kind, + "non-image resources cannot masquerade as decoded pixels"); + resource_free(&wrong_kind); + + let wrong_format: RenderResource = test_resource(RenderResourceKind.image, + "RGBA", 2, 1, 1, RenderColorSpace.srgb); + expect(state, image_resource_validate(wrong_format) == + ImagePayloadError.unsupported_format, + "unknown decoded-image schemas fail closed"); + resource_free(&wrong_format); + + let wrong_size: RenderResource = test_resource(RenderResourceKind.image, + "RGB", image_resource_format_tag(), 1, 1, RenderColorSpace.srgb); + expect(state, image_resource_validate(wrong_size) == ImagePayloadError.size_mismatch, + "RGBA8 payload length must match dimensions exactly"); + resource_free(&wrong_size); + + let no_color: RenderResource = test_resource(RenderResourceKind.image, + "RGBA", image_resource_format_tag(), 1, 1, RenderColorSpace.none); + expect(state, image_resource_validate(no_color) == + ImagePayloadError.invalid_color_space, + "decoded images require explicit color-space truth"); + resource_free(&no_color); + + let no_dimensions: RenderResource = test_resource(RenderResourceKind.image, + "RGBA", image_resource_format_tag(), 0, 1, RenderColorSpace.srgb); + expect(state, image_resource_validate(no_dimensions) == + ImagePayloadError.invalid_dimensions, + "zero dimensions fail before size arithmetic"); + resource_free(&no_dimensions); +} + +fn bounds_contract(state: *TestState) void { + expect(state, image_payload_expected_bytes(1, 1) == 4 && + image_payload_expected_bytes(1920, 1080) == 8294400, + "decoded RGBA8 byte accounting is exact"); + expect(state, image_payload_expected_bytes(0, 1) == 0 && + image_payload_expected_bytes(1048577, 1) == 0, + "invalid dimensions never produce allocation sizes"); + expect(state, image_payload_expected_bytes(1048576, 1048576) == 0, + "multiplication beyond the hard byte domain fails without overflow"); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + validation_contract(&state); + bounds_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Image contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); + _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 59ba02d..ef5e263 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -40,6 +40,8 @@ cd "$root" "$tmp/render-resources-contract" "$znc" tests/path_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/path-contract" "$tmp/path-contract" +"$znc" tests/image_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/image-contract" +"$tmp/image-contract" "$znc" tests/display_list_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/display-list-contract" "$tmp/display-list-contract" "$znc" tests/display_list_codec_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/display-list-codec-contract" @@ -53,4 +55,4 @@ cd "$root" "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths, display lists, CPU shape raster, input, replay, and motion)\n' +printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths and images, display lists, CPU shape and image raster, input, replay, and motion)\n' From d32899050000eef2a5985b4e32814684c33c5671 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 10:11:49 -0700 Subject: [PATCH 028/103] Encode deterministic PNG snapshots --- CHANGELOG.md | 3 + README.md | 10 +- contracts/toolchain.json | 9 +- contracts/upstream-zag.json | 10 +- docs/architecture/README.md | 1 + docs/milestones/0000-product-contract.md | 7 +- docs/quality/png-snapshots.md | 25 ++++ src/render/png_encode.zag | 115 +++++++++++++++++ tests/png_encode_contract.zag | 149 +++++++++++++++++++++++ tools/test-headless.sh | 4 +- 10 files changed, 323 insertions(+), 10 deletions(-) create mode 100644 docs/quality/png-snapshots.md create mode 100644 src/render/png_encode.zag create mode 100644 tests/png_encode_contract.zag diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c2ccaf..739774d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,9 @@ include a generated capability record and exact Zag compiler revision. - Canonical decoded RGBA8 image resources with overflow-safe dimensional validation, exact payload sizing, explicit color-space truth, deterministic premultiplied bilinear CPU sampling, fractional-edge coverage, and opacity. +- Deterministic PNG serialization for CPU surfaces with explicit sRGB intent, + exact filter-zero RGBA8 rows, CRC-checked chunks, a pure-Zag stored-zlib IDAT + stream, malformed-surface rejection, and byte-identical output evidence. - An experimental immutable display list for explicit paints, resources, clips, transforms, layers, and effects with deterministic content identity. - A versioned canonical display-list codec with bounded validated decoding and diff --git a/README.md b/README.md index 72e7b6f..aa84fc5 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,14 @@ clip and transform state, and operation opacity. Unsupported color conversion, glyphs, path strokes, skew, layers, and effects fail at the exact operation; this subset is not yet the complete CPU renderer required by Milestone 2. +Verified CPU surfaces now serialize to deterministic PNG snapshot bytes with +an explicit sRGB chunk, exact RGBA8 rows, valid CRC32 chunks, and a pure-Zag +stored-zlib IDAT stream. Identical pixels produce byte-identical files without +timestamps or host metadata. Persistence, manifests, golden comparison, +Talkback evidence bundles, and native screenshot capture remain open, so this +does not yet claim the snapshot runner or screenshot release gate. See the +[PNG snapshot contract](docs/quality/png-snapshots.md). + The first input slice resolves full affine transforms back to local coordinates, honors local clips and z-order, rejects singular or malformed hit nodes, and routes pointer phases through explicit capture and focus truth. Capture loss, @@ -195,7 +203,7 @@ Run the deterministic headless foundation test with: ``` The script compiles every executable contract with strict Zag semantic -analysis. CI checks out the exact compiler revision from the toolchain contract +analysis. CI checks out the exact compiler revision from the toolchain contract, self-hosts that pinned source to a byte-identical compiler fixed point, and runs the same suite before validating repository metadata. The committed upstream seed is bootstrap authority, not evidence that it already contains later diff --git a/contracts/toolchain.json b/contracts/toolchain.json index c6f09c1..ebea5c2 100644 --- a/contracts/toolchain.json +++ b/contracts/toolchain.json @@ -3,17 +3,18 @@ "zagkit_version": "0.1.0-experimental.0", "zag": { "repository": "https://github.com/Sylorlabs/zag", - "resolved_ref": "refs/heads/zag-v2-machine-control", - "commit": "310e389fbf62b5facab62434e16d38340e978277", + "resolved_ref": "refs/heads/agent/zagkit-prerequisites", + "commit": "b5fa3ff7ba016208f21ce8d3fc55da07a61f911a", "compiler_version": "2026.07.0-dev", "edition": "2026" }, "audited_on": "2026-08-06", "pin_policy": "Every Zagkit release replaces this record with the exact Zag source commit used to build and test it. Branch names and local dirty state are never release identities.", "evidence": [ - "Zag commit 310e389fbf62b5facab62434e16d38340e978277 was exported from the exact clean Git tree and audited on 2026-08-06.", + "Zag commit b5fa3ff7ba016208f21ce8d3fc55da07a61f911a was exported from the exact clean Git tree and audited on 2026-08-06.", "zag-poc/zag.mod at the pinned commit declares version 2026.07.0-dev and edition 2026.", "The clean pinned tree self-hosted to a byte-identical stage-2/stage-3 fixpoint with no external tools and rebuilt zagd.", - "The clean pinned tree passed resource embedding and aggregate switch value-flow witnesses on x86-64 and qemu-user ARM64 plus foreground-cache, cache-integration, and tooling gates." + "The clean pinned tree passed resource embedding and aggregate switch value-flow witnesses on x86-64 and qemu-user ARM64 plus foreground-cache, cache-integration, and tooling gates.", + "The pinned tree provides pure-Zag CRC32, Adler32, and deterministic stored-zlib primitives; their focused suite passes published vectors, exact interoperable bytes, block boundaries, corruption, truncation, and cleanup." ] } diff --git a/contracts/upstream-zag.json b/contracts/upstream-zag.json index 8db3f38..986b355 100644 --- a/contracts/upstream-zag.json +++ b/contracts/upstream-zag.json @@ -1,7 +1,7 @@ { "schema_version": 1, "audited_repository": "https://github.com/Sylorlabs/zag", - "audited_commit": "310e389fbf62b5facab62434e16d38340e978277", + "audited_commit": "b5fa3ff7ba016208f21ce8d3fc55da07a61f911a", "audited_on": "2026-08-06", "state_vocabulary": ["available", "partial", "missing"], "evidence_rule": "Available requires native executable conformance at the audited commit. Source presence, a cross compile, or a dirty working tree does not qualify.", @@ -125,6 +125,14 @@ "needed_by": [2, 3, 5, 6, 7], "evidence": "The pinned zagd architecture documents a checksummed incremental declaration index and background semantic rechecking, but not an in-process incremental parser or stable state-preserving library reload contract.", "exit_gate": "Dependency invalidation, state preserving reload, ABI rejection, rollback, file race, crash recovery, and deterministic event hooks pass end to end." + }, + { + "id": "checksum-and-stored-zlib", + "category": "standard-library", + "state": "available", + "needed_by": [2, 3, 6, 7], + "evidence": "The exact clean pinned commit contains pure-Zag IEEE CRC32, Adler32, and canonical stored-block zlib encode/decode. tests/run_compression_primitives.sh passes published vectors, exact interoperable bytes, empty input, 65535-byte block boundaries, corruption, truncation, trailing-data, bounded-output, and cleanup cases.", + "exit_gate": "Keep the focused checksum and stored-zlib suite green at the exact Zag revision pinned by each Zagkit release." } ] } diff --git a/docs/architecture/README.md b/docs/architecture/README.md index b4277b0..2e19673 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -33,6 +33,7 @@ Detailed experimental contracts: - [motion scheduler and tracks](motion.md) - [canonical vector paths](paths.md) - [canonical decoded images](images.md) +- [deterministic PNG snapshot bytes](../quality/png-snapshots.md) The first experimental compiled slice now fixes the initial Zag shapes for `NodeKey`, `State`, `Binding`, `Action`, `Environment`, `ViewContext`, diff --git a/docs/milestones/0000-product-contract.md b/docs/milestones/0000-product-contract.md index 471d60d..03b3e6f 100644 --- a/docs/milestones/0000-product-contract.md +++ b/docs/milestones/0000-product-contract.md @@ -24,11 +24,12 @@ ## Zag audit boundary -The release pin resolves the clean Zag `zag-v2-machine-control` commit -`310e389fbf62b5facab62434e16d38340e978277`. It was exported from the exact Git +The release pin resolves the clean Zag `agent/zagkit-prerequisites` commit +`b5fa3ff7ba016208f21ce8d3fc55da07a61f911a`. It was exported from the exact Git tree, self-hosted to a byte-identical stage-2/stage-3 fixpoint without external tools, and passed the resource, aggregate switch value-flow, foreground-cache, -cache-integration, and tooling gates. The neighboring local Zag checkout still contains extensive unrelated +cache-integration, tooling, checksum, and stored-zlib gates. The neighboring +local Zag checkout still contains extensive unrelated changes; those changes were not used to claim capabilities or construct this release identity. diff --git a/docs/quality/png-snapshots.md b/docs/quality/png-snapshots.md new file mode 100644 index 0000000..adc85be --- /dev/null +++ b/docs/quality/png-snapshots.md @@ -0,0 +1,25 @@ +# Deterministic PNG snapshot bytes + +Status: experimental headless evidence primitive + +`png_encode_surface` converts a verified owned `CpuSurface` into one canonical +PNG byte stream. It writes the PNG signature, RGBA8 noninterlaced `IHDR`, an +explicit perceptual `sRGB` chunk, one `IDAT`, and `IEND`. Rows use PNG filter +zero. Chunk CRC32, the zlib Adler32 trailer, and stored DEFLATE blocks come from +the pinned pure-Zag standard-library primitives rather than foreign codecs. + +The encoder writes no timestamps, text, host metadata, or adaptive compression +choices. Identical CPU pixels and dimensions therefore produce byte-identical +PNG evidence. Width, height, pixel byte count, scanline size, zlib input, every +chunk CRC, decompressed scanline bytes, ordering, and absence of trailing data +have executable contracts. + +Stored DEFLATE intentionally favors a small deterministic trusted surface over +file size. PNG decoding is not implemented by this encoder, and a valid PNG +file does not by itself certify layout, accessibility, interaction, or native +presentation. + +File persistence, manifest naming, golden comparison, tolerances, retained +Talkback evidence bundles, and actual native screenshot capture remain open. +Until those land, this primitive is deterministic snapshot serialization rather +than the complete snapshot runner or screenshot gate. diff --git a/src/render/png_encode.zag b/src/render/png_encode.zag new file mode 100644 index 0000000..91ff9ed --- /dev/null +++ b/src/render/png_encode.zag @@ -0,0 +1,115 @@ +@import("std:list") +@import("std:checksum") +@import("std:zlib_store") +@import("cpu_raster.zag") + +enum PngEncodeError { + none, + invalid_surface, + input_limit, + compression_error, +} + +struct PngEncodeResult { + bytes: ArrayList[u8], + error: PngEncodeError, +} + +fn png_write_u32_be(bytes: *ArrayList[u8], value: i64) void { + push[u8](bytes, ((value >> 24) & 255) as u8); + push[u8](bytes, ((value >> 16) & 255) as u8); + push[u8](bytes, ((value >> 8) & 255) as u8); + push[u8](bytes, (value & 255) as u8); +} + +fn png_write_chunk(output: *ArrayList[u8], chunk_type: []u8, data: []u8) void { + png_write_u32_be(output, data.len as i64); + let crc_input: ArrayList[u8] = make[u8](4 + data.len); + let i: i32 = 0; + while (i < 4) { + push[u8](output, chunk_type[i]); + push[u8](&crc_input, chunk_type[i]); + i = i + 1; + } + i = 0; + while (i < data.len) { + push[u8](output, data[i]); + push[u8](&crc_input, data[i]); + i = i + 1; + } + png_write_u32_be(output, + checksum_crc32(crc_input.data[0..crc_input.len])); + free[u8](&crc_input); +} + +fn png_encode_fail(error: PngEncodeError) PngEncodeResult { + return PngEncodeResult{ .bytes = make[u8](1), .error = error }; +} + +fn png_encode_surface(surface: CpuSurface) PngEncodeResult { + if (surface.width <= 0 || surface.height <= 0 || + surface.width > 8192 || surface.height > 8192) { + return png_encode_fail(PngEncodeError.invalid_surface); + } + let pixel_bytes: i64 = (surface.width as i64) * + (surface.height as i64) * 4; + if (pixel_bytes <= 0 || pixel_bytes != surface.pixels.len as i64) { + return png_encode_fail(PngEncodeError.invalid_surface); + } + let scanline_bytes: i64 = pixel_bytes + surface.height as i64; + if (scanline_bytes <= 0 || scanline_bytes > zlib_store_input_limit()) { + return png_encode_fail(PngEncodeError.input_limit); + } + let scanlines: ArrayList[u8] = make[u8](scanline_bytes as i32); + let y: i32 = 0; + while (y < surface.height) { + push[u8](&scanlines, 0); + let row_start: i32 = y * surface.width * 4; + let i: i32 = 0; + while (i < surface.width * 4) { + push[u8](&scanlines, surface.pixels.data[row_start + i]); + i = i + 1; + } + y = y + 1; + } + let compressed: ZlibStoreEncodeResult = zlib_store_encode( + scanlines.data[0..scanlines.len] + ); + free[u8](&scanlines); + if (compressed.error != ZlibStoreError.none) { + zlib_store_encode_free(&compressed); + return png_encode_fail(PngEncodeError.compression_error); + } + + let output: ArrayList[u8] = make[u8](compressed.bytes.len + 64); + push[u8](&output, 137); push[u8](&output, 80); + push[u8](&output, 78); push[u8](&output, 71); + push[u8](&output, 13); push[u8](&output, 10); + push[u8](&output, 26); push[u8](&output, 10); + + let header: ArrayList[u8] = make[u8](13); + png_write_u32_be(&header, surface.width as i64); + png_write_u32_be(&header, surface.height as i64); + push[u8](&header, 8); + push[u8](&header, 6); + push[u8](&header, 0); + push[u8](&header, 0); + push[u8](&header, 0); + png_write_chunk(&output, "IHDR", header.data[0..header.len]); + free[u8](&header); + + let srgb: ArrayList[u8] = make[u8](1); + push[u8](&srgb, 0); + png_write_chunk(&output, "sRGB", srgb.data[0..srgb.len]); + free[u8](&srgb); + + png_write_chunk(&output, "IDAT", + compressed.bytes.data[0..compressed.bytes.len]); + zlib_store_encode_free(&compressed); + png_write_chunk(&output, "IEND", ""); + return PngEncodeResult{ .bytes = output, .error = PngEncodeError.none }; +} + +fn png_encode_free(result: *PngEncodeResult) void { + free[u8](&result.*.bytes); +} diff --git a/tests/png_encode_contract.zag b/tests/png_encode_contract.zag new file mode 100644 index 0000000..b40b3a0 --- /dev/null +++ b/tests/png_encode_contract.zag @@ -0,0 +1,149 @@ +@import("../src/render/png_encode.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn read_u32_be(bytes: ArrayList[u8], offset: i32) i64 { + return ((bytes.data[offset] as i64) << 24) | + ((bytes.data[offset + 1] as i64) << 16) | + ((bytes.data[offset + 2] as i64) << 8) | + (bytes.data[offset + 3] as i64); +} + +fn bytes_equal(a: ArrayList[u8], b: ArrayList[u8]) i32 { + if (a.len != b.len) { return 0; } + let i: i32 = 0; + while (i < a.len) { + if (a.data[i] != b.data[i]) { return 0; } + i = i + 1; + } + return 1; +} + +fn chunk_crc_valid(bytes: ArrayList[u8], offset: i32) i32 { + let length: i32 = read_u32_be(bytes, offset) as i32; + if (length < 0 || offset + 12 + length > bytes.len) { return 0; } + let crc_input: ArrayList[u8] = make[u8](4 + length); + let i: i32 = 0; + while (i < 4 + length) { + push[u8](&crc_input, bytes.data[offset + 4 + i]); + i = i + 1; + } + let expected: i64 = checksum_crc32(crc_input.data[0..crc_input.len]); + free[u8](&crc_input); + return (expected == read_u32_be(bytes, offset + 8 + length)) as i32; +} + +fn signature_and_chunks_contract(state: *TestState) void { + let surface: CpuSurface = cpu_surface_make(1, 1); + surface.pixels.data[0] = 17; + surface.pixels.data[1] = 34; + surface.pixels.data[2] = 51; + surface.pixels.data[3] = 68; + let png: PngEncodeResult = png_encode_surface(surface); + expect(state, png.error == PngEncodeError.none && png.bytes.len == 86, + "one RGBA8 pixel produces the exact canonical PNG size"); + expect(state, png.bytes.data[0] == 137 && png.bytes.data[1] == 80 && + png.bytes.data[2] == 78 && png.bytes.data[3] == 71 && + png.bytes.data[4] == 13 && png.bytes.data[5] == 10 && + png.bytes.data[6] == 26 && png.bytes.data[7] == 10, + "snapshot bytes begin with the PNG signature"); + + let ihdr: i32 = 8; + let srgb: i32 = ihdr + 25; + let idat: i32 = srgb + 13; + let idat_length: i32 = read_u32_be(png.bytes, idat) as i32; + let iend: i32 = idat + 12 + idat_length; + expect(state, read_u32_be(png.bytes, ihdr) == 13 && + png.bytes.data[ihdr + 4] == 73 && png.bytes.data[ihdr + 5] == 72 && + png.bytes.data[ihdr + 6] == 68 && png.bytes.data[ihdr + 7] == 82 && + read_u32_be(png.bytes, ihdr + 8) == 1 && + read_u32_be(png.bytes, ihdr + 12) == 1 && + png.bytes.data[ihdr + 16] == 8 && png.bytes.data[ihdr + 17] == 6, + "IHDR declares one by one noninterlaced RGBA8 pixels"); + expect(state, png.bytes.data[srgb + 4] == 115 && + png.bytes.data[srgb + 5] == 82 && png.bytes.data[srgb + 6] == 71 && + png.bytes.data[srgb + 7] == 66 && png.bytes.data[srgb + 8] == 0, + "snapshot color intent is explicitly sRGB perceptual"); + expect(state, chunk_crc_valid(png.bytes, ihdr) != 0 && + chunk_crc_valid(png.bytes, srgb) != 0 && + chunk_crc_valid(png.bytes, idat) != 0 && + chunk_crc_valid(png.bytes, iend) != 0, + "every PNG chunk carries a valid IEEE CRC32"); + expect(state, png.bytes.data[iend + 4] == 73 && + png.bytes.data[iend + 5] == 69 && png.bytes.data[iend + 6] == 78 && + png.bytes.data[iend + 7] == 68 && iend + 12 == png.bytes.len, + "IEND terminates the file with no trailing bytes"); + + let decoded: ZlibStoreDecodeResult = zlib_store_decode( + png.bytes.data[idat + 8..idat + 8 + idat_length] + ); + expect(state, decoded.error == ZlibStoreError.none && decoded.bytes.len == 5 && + decoded.bytes.data[0] == 0 && decoded.bytes.data[1] == 17 && + decoded.bytes.data[2] == 34 && decoded.bytes.data[3] == 51 && + decoded.bytes.data[4] == 68, + "IDAT decodes to filter zero followed by exact RGBA bytes"); + zlib_store_decode_free(&decoded); + png_encode_free(&png); + cpu_surface_free(&surface); +} + +fn determinism_contract(state: *TestState) void { + let surface: CpuSurface = cpu_surface_make(3, 2); + let i: i32 = 0; + while (i < surface.pixels.len) { + surface.pixels.data[i] = ((i * 29 + 7) % 256) as u8; + i = i + 1; + } + let first: PngEncodeResult = png_encode_surface(surface); + let second: PngEncodeResult = png_encode_surface(surface); + expect(state, first.error == PngEncodeError.none && + second.error == PngEncodeError.none && bytes_equal(first.bytes, second.bytes) != 0, + "identical CPU surfaces produce byte-identical PNG snapshots"); + png_encode_free(&second); + png_encode_free(&first); + cpu_surface_free(&surface); +} + +fn rejection_contract(state: *TestState) void { + let invalid: CpuSurface = cpu_surface_make(0, 1); + let invalid_result: PngEncodeResult = png_encode_surface(invalid); + expect(state, invalid_result.error == PngEncodeError.invalid_surface && + invalid_result.bytes.len == 0, + "invalid surfaces cannot become screenshot evidence"); + png_encode_free(&invalid_result); + cpu_surface_free(&invalid); + + let mutated: CpuSurface = cpu_surface_make(1, 1); + mutated.pixels.len = 3; + let mutated_result: PngEncodeResult = png_encode_surface(mutated); + expect(state, mutated_result.error == PngEncodeError.invalid_surface, + "surface byte-count mutation fails before PNG encoding"); + png_encode_free(&mutated_result); + mutated.pixels.len = 4; + cpu_surface_free(&mutated); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + signature_and_chunks_contract(&state); + determinism_contract(&state); + rejection_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("PNG encode contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); + _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index ef5e263..caf7ac9 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -42,6 +42,8 @@ cd "$root" "$tmp/path-contract" "$znc" tests/image_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/image-contract" "$tmp/image-contract" +"$znc" tests/png_encode_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/png-encode-contract" +"$tmp/png-encode-contract" "$znc" tests/display_list_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/display-list-contract" "$tmp/display-list-contract" "$znc" tests/display_list_codec_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/display-list-codec-contract" @@ -55,4 +57,4 @@ cd "$root" "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths and images, display lists, CPU shape and image raster, input, replay, and motion)\n' +printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths and images, display lists, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion)\n' From 55aadf19fdf720839ddda61995515b8e61e4fe86 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 10:15:03 -0700 Subject: [PATCH 029/103] Add persisted headless reference snapshot --- CHANGELOG.md | 2 + README.md | 8 +- docs/quality/png-snapshots.md | 16 +++- tools/render-headless-reference.sh | 19 ++++ tools/render-headless-reference.zag | 140 ++++++++++++++++++++++++++++ tools/test-headless.sh | 7 ++ 6 files changed, 185 insertions(+), 7 deletions(-) create mode 100755 tools/render-headless-reference.sh create mode 100644 tools/render-headless-reference.zag diff --git a/CHANGELOG.md b/CHANGELOG.md index 739774d..006fd01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,8 @@ include a generated capability record and exact Zag compiler revision. - Deterministic PNG serialization for CPU surfaces with explicit sRGB intent, exact filter-zero RGBA8 rows, CRC-checked chunks, a pure-Zag stored-zlib IDAT stream, malformed-surface rejection, and byte-identical output evidence. +- A pure-Zag persisted headless reference scene whose PNG signature, substantial + output, and two-run byte identity execute in the normal repository gate. - An experimental immutable display list for explicit paints, resources, clips, transforms, layers, and effects with deterministic content identity. - A versioned canonical display-list codec with bounded validated decoding and diff --git a/README.md b/README.md index aa84fc5..23047d7 100644 --- a/README.md +++ b/README.md @@ -163,9 +163,11 @@ this subset is not yet the complete CPU renderer required by Milestone 2. Verified CPU surfaces now serialize to deterministic PNG snapshot bytes with an explicit sRGB chunk, exact RGBA8 rows, valid CRC32 chunks, and a pure-Zag stored-zlib IDAT stream. Identical pixels produce byte-identical files without -timestamps or host metadata. Persistence, manifests, golden comparison, -Talkback evidence bundles, and native screenshot capture remain open, so this -does not yet claim the snapshot runner or screenshot release gate. See the +timestamps or host metadata. The pure-Zag headless reference runner persists a +320 by 200 conformance scene twice and the standard gate verifies signature, +nonempty size, and byte identity. Manifests, golden comparison, Talkback +evidence bundles, and native screenshot capture remain open, so this does not +yet claim the complete snapshot runner or screenshot release gate. See the [PNG snapshot contract](docs/quality/png-snapshots.md). The first input slice resolves full affine transforms back to local coordinates, diff --git a/docs/quality/png-snapshots.md b/docs/quality/png-snapshots.md index adc85be..0142adc 100644 --- a/docs/quality/png-snapshots.md +++ b/docs/quality/png-snapshots.md @@ -19,7 +19,15 @@ file size. PNG decoding is not implemented by this encoder, and a valid PNG file does not by itself certify layout, accessibility, interaction, or native presentation. -File persistence, manifest naming, golden comparison, tolerances, retained -Talkback evidence bundles, and actual native screenshot capture remain open. -Until those land, this primitive is deterministic snapshot serialization rather -than the complete snapshot runner or screenshot gate. +`tools/render-headless-reference.sh ` compiles and runs a pure-Zag +320 by 200 conformance scene and persists its PNG. The normal headless gate +renders it twice, requires byte identity, checks the PNG signature, and rejects +unexpectedly empty output. The scene exercises fixed-point fills, centered +strokes, alpha composition, a curved path, decoded image scaling, CPU raster, +PNG encoding, and file output. It deliberately contains no product typography +or selected design direction. + +Manifest naming, golden comparison, tolerances, retained Talkback evidence +bundles, and actual native screenshot capture remain open. Until those land, +this is a deterministic headless reference runner rather than the complete +snapshot runner or screenshot gate. diff --git a/tools/render-headless-reference.sh b/tools/render-headless-reference.sh new file mode 100755 index 0000000..8b09d6b --- /dev/null +++ b/tools/render-headless-reference.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +ZNC=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +TMP=$(mktemp -d /tmp/zagkit-reference.XXXXXX) +trap 'find "$TMP" -depth -delete' EXIT + +cd "$ROOT" +"$ZNC" tools/render-headless-reference.zag \ + --no-zagd --analyze-strict --no-foreground-cache \ + -o "$TMP/render-headless-reference" +"$TMP/render-headless-reference" "$1" diff --git a/tools/render-headless-reference.zag b/tools/render-headless-reference.zag new file mode 100644 index 0000000..5a85a35 --- /dev/null +++ b/tools/render-headless-reference.zag @@ -0,0 +1,140 @@ +@import("../src/render/png_encode.zag") + +fn reference_push_fill(list: *DisplayList, owner: i64, bounds: Rect, + paint: Paint) i32 { + let op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(owner)); + op.bounds = bounds; + op.paint = paint; + return (display_list_push(list, op) == DisplayError.none) as i32; +} + +fn reference_push_stroke(list: *DisplayList, owner: i64, bounds: Rect, + paint: Paint, width: i64) i32 { + let op: DisplayOp = display_op(DisplayOpKind.stroke_rect, node_key(owner)); + op.bounds = bounds; + op.paint = paint; + op.paint.stroke_width = width; + return (display_list_push(list, op) == DisplayError.none) as i32; +} + +fn reference_add_accent_path(list: *DisplayList) i32 { + let path: PathData = path_make(PathFillRule.non_zero); + _ = path_move_to(&path, path_point(238 * unit_scale(), 48 * unit_scale())); + _ = path_cubic_to(&path, + path_point(280 * unit_scale(), 34 * unit_scale()), + path_point(302 * unit_scale(), 70 * unit_scale()), + path_point(276 * unit_scale(), 98 * unit_scale())); + _ = path_quad_to(&path, + path_point(252 * unit_scale(), 116 * unit_scale()), + path_point(232 * unit_scale(), 88 * unit_scale())); + _ = path_close(&path); + if (path_seal(&path) != PathError.none) { + path_free(&path); + return 0; + } + let payload: PathEncodeResult = path_encode(path); + path_free(&path); + let error: RenderResourceError = display_list_add_resource(list, + render_resource_spec(700, RenderResourceKind.path, + payload.bytes.data[0..payload.bytes.len], path_resource_format_tag()) + ); + path_encode_free(&payload); + if (error != RenderResourceError.none) { return 0; } + let op: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(700)); + op.resource_id = 700; + op.paint = paint_rgba16(24576, 49152, 65535, 56000); + return (display_list_push(list, op) == DisplayError.none) as i32; +} + +fn reference_add_image(list: *DisplayList) i32 { + let pixels: ArrayList[u8] = make[u8](16); + push[u8](&pixels, 35); push[u8](&pixels, 217); push[u8](&pixels, 255); push[u8](&pixels, 255); + push[u8](&pixels, 120); push[u8](&pixels, 80); push[u8](&pixels, 255); push[u8](&pixels, 255); + push[u8](&pixels, 245); push[u8](&pixels, 120); push[u8](&pixels, 185); push[u8](&pixels, 255); + push[u8](&pixels, 20); push[u8](&pixels, 38); push[u8](&pixels, 78); push[u8](&pixels, 255); + let spec: RenderResourceSpec = render_resource_spec( + 701, RenderResourceKind.image, pixels.data[0..pixels.len], + image_resource_format_tag() + ); + spec.width = 2; + spec.height = 2; + spec.color_space = RenderColorSpace.srgb; + let error: RenderResourceError = display_list_add_resource(list, spec); + free[u8](&pixels); + if (error != RenderResourceError.none) { return 0; } + let op: DisplayOp = display_op(DisplayOpKind.draw_image, node_key(701)); + op.resource_id = 701; + op.bounds = rect(228 * unit_scale(), 118 * unit_scale(), + 70 * unit_scale(), 54 * unit_scale()); + op.paint.alpha = 52000; + return (display_list_push(list, op) == DisplayError.none) as i32; +} + +fn reference_scene() DisplayList { + let list: DisplayList = display_list_make(); + _ = reference_push_fill(&list, 1, rect(0, 0, 320 * unit_scale(), 200 * unit_scale()), + paint_rgba16(2600, 3900, 8200, 65535)); + _ = reference_push_fill(&list, 2, rect(0, 0, 320 * unit_scale(), 28 * unit_scale()), + paint_rgba16(6200, 8200, 15000, 62000)); + _ = reference_push_stroke(&list, 3, rect(0, 28 * unit_scale(), + 320 * unit_scale(), unit_scale()), paint_rgba16(16000, 22000, 36000, 36000), + unit_scale()); + _ = reference_push_fill(&list, 4, rect(12 * unit_scale(), 42 * unit_scale(), + 72 * unit_scale(), 144 * unit_scale()), paint_rgba16(7000, 10000, 18000, 52000)); + _ = reference_push_stroke(&list, 5, rect(12 * unit_scale(), 42 * unit_scale(), + 72 * unit_scale(), 144 * unit_scale()), paint_rgba16(18000, 26000, 43000, 42000), + unit_scale()); + _ = reference_push_fill(&list, 6, rect(96 * unit_scale(), 42 * unit_scale(), + 112 * unit_scale(), 62 * unit_scale()), paint_rgba16(10000, 13500, 23500, 56000)); + _ = reference_push_stroke(&list, 7, rect(96 * unit_scale(), 42 * unit_scale(), + 112 * unit_scale(), 62 * unit_scale()), paint_rgba16(22000, 34000, 54000, 46000), + unit_scale()); + _ = reference_push_fill(&list, 8, rect(96 * unit_scale(), 116 * unit_scale(), + 112 * unit_scale(), 70 * unit_scale()), paint_rgba16(8200, 11200, 20500, 54000)); + _ = reference_push_stroke(&list, 9, rect(96 * unit_scale(), 116 * unit_scale(), + 112 * unit_scale(), 70 * unit_scale()), paint_rgba16(18000, 30000, 50000, 42000), + unit_scale()); + _ = reference_push_fill(&list, 10, rect(24 * unit_scale(), 56 * unit_scale(), + 48 * unit_scale(), 8 * unit_scale()), paint_rgba16(12000, 52000, 65535, 57000)); + _ = reference_push_fill(&list, 11, rect(24 * unit_scale(), 76 * unit_scale(), + 36 * unit_scale(), 5 * unit_scale()), paint_rgba16(26000, 33000, 47000, 50000)); + _ = reference_push_fill(&list, 12, rect(24 * unit_scale(), 91 * unit_scale(), + 44 * unit_scale(), 5 * unit_scale()), paint_rgba16(26000, 33000, 47000, 50000)); + _ = reference_add_accent_path(&list); + _ = reference_add_image(&list); + _ = display_list_seal(&list); + return list; +} + +fn main() i32 { + if (_zag_argc() != 2) { + _zag_eprintln("usage: render-headless-reference "); + return 2; + } + let list: DisplayList = reference_scene(); + if (display_list_verify(list) != DisplayError.none) { + display_list_free(&list); + return 3; + } + let raster: CpuRasterResult = cpu_rasterize(list, 320, 200); + if (raster.error != CpuRasterError.none) { + cpu_raster_free(&raster); + display_list_free(&list); + return 4; + } + let png: PngEncodeResult = png_encode_surface(raster.surface); + if (png.error != PngEncodeError.none) { + png_encode_free(&png); + cpu_raster_free(&raster); + display_list_free(&list); + return 5; + } + let write_error: i32 = _zag_write_file( + _zag_arg(1), png.bytes.data[0..png.bytes.len] + ); + png_encode_free(&png); + cpu_raster_free(&raster); + display_list_free(&list); + if (write_error != 0) { return 6; } + return 0; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index caf7ac9..36f37fa 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -44,6 +44,13 @@ cd "$root" "$tmp/image-contract" "$znc" tests/png_encode_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/png-encode-contract" "$tmp/png-encode-contract" +"$znc" tools/render-headless-reference.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/render-headless-reference" +"$tmp/render-headless-reference" "$tmp/reference-first.png" +"$tmp/render-headless-reference" "$tmp/reference-second.png" +[ "$(wc -c < "$tmp/reference-first.png")" -gt 100000 ] +[ "$(od -An -tx1 -N8 "$tmp/reference-first.png" | tr -d ' \n')" = "89504e470d0a1a0a" ] +cmp -s "$tmp/reference-first.png" "$tmp/reference-second.png" +printf 'Reference snapshot contract: pass=3 fail=0\n' "$znc" tests/display_list_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/display-list-contract" "$tmp/display-list-contract" "$znc" tests/display_list_codec_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/display-list-codec-contract" From cdcd440862fe57eb1704cf84d7307011e738b4b6 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 12:24:15 -0700 Subject: [PATCH 030/103] Pin general zlib inflate prerequisite --- contracts/toolchain.json | 6 +++--- contracts/upstream-zag.json | 8 ++++---- docs/milestones/0000-product-contract.md | 5 +++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/contracts/toolchain.json b/contracts/toolchain.json index ebea5c2..9101421 100644 --- a/contracts/toolchain.json +++ b/contracts/toolchain.json @@ -4,17 +4,17 @@ "zag": { "repository": "https://github.com/Sylorlabs/zag", "resolved_ref": "refs/heads/agent/zagkit-prerequisites", - "commit": "b5fa3ff7ba016208f21ce8d3fc55da07a61f911a", + "commit": "63e3979d91bfd06f53ee317bd6bf428673814bdf", "compiler_version": "2026.07.0-dev", "edition": "2026" }, "audited_on": "2026-08-06", "pin_policy": "Every Zagkit release replaces this record with the exact Zag source commit used to build and test it. Branch names and local dirty state are never release identities.", "evidence": [ - "Zag commit b5fa3ff7ba016208f21ce8d3fc55da07a61f911a was exported from the exact clean Git tree and audited on 2026-08-06.", + "Zag commit 63e3979d91bfd06f53ee317bd6bf428673814bdf was exported from the exact clean Git tree and audited on 2026-08-06.", "zag-poc/zag.mod at the pinned commit declares version 2026.07.0-dev and edition 2026.", "The clean pinned tree self-hosted to a byte-identical stage-2/stage-3 fixpoint with no external tools and rebuilt zagd.", "The clean pinned tree passed resource embedding and aggregate switch value-flow witnesses on x86-64 and qemu-user ARM64 plus foreground-cache, cache-integration, and tooling gates.", - "The pinned tree provides pure-Zag CRC32, Adler32, and deterministic stored-zlib primitives; their focused suite passes published vectors, exact interoperable bytes, block boundaries, corruption, truncation, and cleanup." + "The pinned tree provides pure-Zag CRC32, Adler32, deterministic stored-zlib output, and general stored/fixed/dynamic DEFLATE inflate; the same 14-check suite passes on x86-64 and qemu-user ARM64." ] } diff --git a/contracts/upstream-zag.json b/contracts/upstream-zag.json index 986b355..59fc163 100644 --- a/contracts/upstream-zag.json +++ b/contracts/upstream-zag.json @@ -1,7 +1,7 @@ { "schema_version": 1, "audited_repository": "https://github.com/Sylorlabs/zag", - "audited_commit": "b5fa3ff7ba016208f21ce8d3fc55da07a61f911a", + "audited_commit": "63e3979d91bfd06f53ee317bd6bf428673814bdf", "audited_on": "2026-08-06", "state_vocabulary": ["available", "partial", "missing"], "evidence_rule": "Available requires native executable conformance at the audited commit. Source presence, a cross compile, or a dirty working tree does not qualify.", @@ -127,12 +127,12 @@ "exit_gate": "Dependency invalidation, state preserving reload, ABI rejection, rollback, file race, crash recovery, and deterministic event hooks pass end to end." }, { - "id": "checksum-and-stored-zlib", + "id": "checksum-and-zlib-inflate", "category": "standard-library", "state": "available", "needed_by": [2, 3, 6, 7], - "evidence": "The exact clean pinned commit contains pure-Zag IEEE CRC32, Adler32, and canonical stored-block zlib encode/decode. tests/run_compression_primitives.sh passes published vectors, exact interoperable bytes, empty input, 65535-byte block boundaries, corruption, truncation, trailing-data, bounded-output, and cleanup cases.", - "exit_gate": "Keep the focused checksum and stored-zlib suite green at the exact Zag revision pinned by each Zagkit release." + "evidence": "The exact clean pinned commit contains pure-Zag IEEE CRC32, Adler32, canonical stored-block zlib encode/decode, and bounded general stored/fixed/dynamic DEFLATE inflate. The 12-check primitive suite and 14-check inflater suite pass on x86-64; the same 14 checks pass in a Zag-generated ARM64 ELF under qemu-user, including independent Python-zlib fixtures, mixed blocks, every strict prefix, and single-bit mutations.", + "exit_gate": "Keep the focused checksum, stored-zlib, and cross-target general-inflate suites green at the exact Zag revision pinned by each Zagkit release." } ] } diff --git a/docs/milestones/0000-product-contract.md b/docs/milestones/0000-product-contract.md index 03b3e6f..23de311 100644 --- a/docs/milestones/0000-product-contract.md +++ b/docs/milestones/0000-product-contract.md @@ -25,10 +25,11 @@ ## Zag audit boundary The release pin resolves the clean Zag `agent/zagkit-prerequisites` commit -`b5fa3ff7ba016208f21ce8d3fc55da07a61f911a`. It was exported from the exact Git +`63e3979d91bfd06f53ee317bd6bf428673814bdf`. It was exported from the exact Git tree, self-hosted to a byte-identical stage-2/stage-3 fixpoint without external tools, and passed the resource, aggregate switch value-flow, foreground-cache, -cache-integration, tooling, checksum, and stored-zlib gates. The neighboring +cache-integration, tooling, checksum, stored-zlib, and general DEFLATE gates on +x86-64 and qemu-user ARM64. The neighboring local Zag checkout still contains extensive unrelated changes; those changes were not used to claim capabilities or construct this release identity. From 034e20dcdfd7ddca270484171a42d8ebf9e46e59 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 12:46:38 -0700 Subject: [PATCH 031/103] Add bounded pure-Zag PNG decoding --- README.md | 17 +- docs/architecture/README.md | 2 +- docs/architecture/images.md | 38 ++- docs/quality/png-snapshots.md | 7 +- src/render/png_decode.zag | 574 ++++++++++++++++++++++++++++++++++ tests/png_decode_contract.zag | 453 +++++++++++++++++++++++++++ tools/test-headless.sh | 4 +- 7 files changed, 1084 insertions(+), 11 deletions(-) create mode 100644 src/render/png_decode.zag create mode 100644 tests/png_decode_contract.zag diff --git a/README.md b/README.md index 23047d7..1e9d931 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,9 @@ The version 2 binary codec round-trips resource metadata, payloads, revisions, allocation policy, and operations byte-identically and rejects malformed, truncated, noncanonical, unknown-version, and hash-mismatched input. Scale-adaptive path -coverage, encoded image and glyph interpretation, damage, complete CPU -rasterization, GPU transport, schema evolution, and fuzz coverage remain open. +coverage, remaining encoded-image formats, glyph interpretation, damage, +complete CPU rasterization, GPU transport, schema evolution, and fuzz coverage +remain open. See the [path contract](docs/architecture/paths.md) and [image contract](docs/architecture/images.md). @@ -170,6 +171,18 @@ evidence bundles, and native screenshot capture remain open, so this does not yet claim the complete snapshot runner or screenshot release gate. See the [PNG snapshot contract](docs/quality/png-snapshots.md). +Encoded PNG assets now enter the same canonical image boundary through a +bounded pure-Zag decoder. It validates chunk spelling, order, CRCs, complete +termination, and exact decompressed size; supports stored, fixed, and dynamic +DEFLATE; reverses all five PNG filters; and expands legal noninterlaced +grayscale, RGB, indexed, grayscale-alpha, and RGBA bit depths with PLTE and +tRNS into owned sRGB RGBA8. Focused contracts pass identically on x86-64 and +ARM64/qemu, and an independent 240-file corpus covers every accepted color and +bit-depth family across five zlib strategies. Adam7, arbitrary ICC/chromaticity +conversion, scale goldens, and broader fuzzing remain open, so the `G3-PNG` +completion box remains deliberately unchecked. See the +[image and PNG ingestion contract](docs/architecture/images.md). + The first input slice resolves full affine transforms back to local coordinates, honors local clips and z-order, rejects singular or malformed hit nodes, and routes pointer phases through explicit capture and focus truth. Capture loss, diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 2e19673..3a1d667 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -32,7 +32,7 @@ Detailed experimental contracts: - [Flex placement and adaptive spacing](flex.md) - [motion scheduler and tracks](motion.md) - [canonical vector paths](paths.md) -- [canonical decoded images](images.md) +- [canonical images and bounded PNG ingestion](images.md) - [deterministic PNG snapshot bytes](../quality/png-snapshots.md) The first experimental compiled slice now fixes the initial Zag shapes for diff --git a/docs/architecture/images.md b/docs/architecture/images.md index c5c9d70..fe85ce9 100644 --- a/docs/architecture/images.md +++ b/docs/architecture/images.md @@ -28,9 +28,39 @@ Unsupported color-space conversion fails at the exact draw operation before pixels are touched. The deterministic replay scene exercises the owned image, display-list, and CPU route. -This is not PNG support yet. PNG parsing, decompression, filters, palettes, -grayscale expansion, profiles, bomb limits, and malformed-stream handling must -produce this canonical image form through their own verified decoder. Linear -light filtering, Display P3 conversion, wide-gamut surfaces, mipmapping, +## PNG ingestion + +`png_decode(bytes)` is the owned, fail-closed path from encoded PNG assets to +the canonical image form. It validates the signature, chunk type spelling and +reserved bit, chunk ordering, duplicate single-instance metadata, every CRC32, IEND, +and absence of trailing data before trusting image content. IDAT payloads are +joined under an encoded-input ceiling and decoded by the pinned pure-Zag +stored/fixed/dynamic DEFLATE inflater with the exact expected scanline byte +count as its output ceiling. + +The decoder reverses None, Sub, Up, Average, and Paeth filters. It accepts the +legal noninterlaced grayscale, RGB, indexed, grayscale-alpha, and RGBA bit-depth +combinations, including packed 1/2/4-bit samples, PLTE, and tRNS. Renderer-owned +output is always straight-alpha sRGB RGBA8. Sixteen-bit samples use their high +byte as the current deterministic conversion policy; transparency comparisons +use the original full sample before conversion. Files without explicit color +metadata use Zagkit's documented sRGB fallback policy; this is an assumption +recorded by the asset pipeline, not a claim that the source declared sRGB. An +explicit `sRGB` chunk, or the exact standard sRGB `gAMA` plus `cHRM` pair, +records a declared profile. A matching gamma without declared primaries still +records the fallback as assumed. +`png_decode_resource_spec` +adapts a successful owned result to the normal copying resource-store API, so +the decoder can be freed immediately after insertion. + +Dimension, encoded-data, decompressed-scanline, output-pixel, palette-index, +filter, and arithmetic limits fail before out-of-bounds access. Explicit iCCP, +non-sRGB chromaticity conversion, and non-sRGB gAMA values fail as unsupported +color profiles. +Adam7 interlace also fails explicitly rather than being rendered incorrectly. +Those unavailable paths keep `G3-PNG` open until profile conversion, Adam7, +scale suites, and broader malformed-input fuzzing land. + +Linear-light filtering, Display P3 conversion, wide-gamut surfaces, mipmapping, high-quality downsampling, image tiling, and GPU upload caches also remain unavailable. diff --git a/docs/quality/png-snapshots.md b/docs/quality/png-snapshots.md index 0142adc..92565a2 100644 --- a/docs/quality/png-snapshots.md +++ b/docs/quality/png-snapshots.md @@ -15,9 +15,10 @@ chunk CRC, decompressed scanline bytes, ordering, and absence of trailing data have executable contracts. Stored DEFLATE intentionally favors a small deterministic trusted surface over -file size. PNG decoding is not implemented by this encoder, and a valid PNG -file does not by itself certify layout, accessibility, interaction, or native -presentation. +file size. The separate [`png_decode`](../architecture/images.md) asset path +accepts bounded stored, fixed, and dynamic DEFLATE PNGs; this snapshot encoder +does not share its adaptive input policy. A valid PNG file does not by itself +certify layout, accessibility, interaction, or native presentation. `tools/render-headless-reference.sh ` compiles and runs a pure-Zag 320 by 200 conformance scene and persists its PNG. The normal headless gate diff --git a/src/render/png_decode.zag b/src/render/png_decode.zag new file mode 100644 index 0000000..df4d364 --- /dev/null +++ b/src/render/png_decode.zag @@ -0,0 +1,574 @@ +@import("std:list") +@import("std:checksum") +@import("std:zlib_inflate") +@import("image.zag") + +enum PngDecodeError { + none, + input_limit, + invalid_signature, + truncated, + invalid_chunk_length, + invalid_chunk_type, + invalid_chunk_crc, + invalid_chunk_order, + duplicate_chunk, + unknown_critical_chunk, + invalid_header, + unsupported_color_type, + unsupported_bit_depth, + unsupported_interlace, + unsupported_color_profile, + missing_palette, + invalid_palette, + invalid_transparency, + missing_image_data, + compression_error, + decompressed_size, + invalid_filter, + palette_index, + pixel_limit, + trailing_data, +} + +struct PngDecodeResult { + pixels: ArrayList[u8], + width: i32, + height: i32, + color_space: RenderColorSpace, + profile_assumed: i32, + error: PngDecodeError, + error_offset: i64, +} + +struct PngScanlineResult { + bytes: ArrayList[u8], + error: PngDecodeError, + error_offset: i64, +} + +fn png_decode_input_limit() i64 { return 134217728; } +fn png_decode_pixel_byte_limit() i64 { return 67108864; } +fn png_decode_dimension_limit() i32 { return 8192; } + +fn png_decode_fail(error: PngDecodeError, offset: i64) PngDecodeResult { + return PngDecodeResult{ + .pixels = make[u8](1), .width = 0, .height = 0, + .color_space = RenderColorSpace.none, + .profile_assumed = 0, + .error = error, .error_offset = offset, + }; +} + +fn png_decode_free(result: *PngDecodeResult) void { + free[u8](&result.*.pixels); +} + +fn png_read_u16_be(bytes: []u8, offset: i32) i32 { + return ((bytes[offset] as i32) << 8) | (bytes[offset + 1] as i32); +} + +fn png_read_u32_be(bytes: []u8, offset: i32) i64 { + return ((bytes[offset] as i64) << 24) | + ((bytes[offset + 1] as i64) << 16) | + ((bytes[offset + 2] as i64) << 8) | + (bytes[offset + 3] as i64); +} + +fn png_chunk_is(bytes: []u8, offset: i32, name: []u8) i32 { + if (name.len != 4) { return 0; } + let i: i32 = 0; + while (i < 4) { + if (bytes[offset + 4 + i] != name[i]) { return 0; } + i = i + 1; + } + return 1; +} + +fn png_chunk_type_valid(bytes: []u8, offset: i32) i32 { + let i: i32 = 0; + while (i < 4) { + let c: u8 = bytes[offset + 4 + i]; + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) { return 0; } + i = i + 1; + } + return ((bytes[offset + 6] & 32) == 0) as i32; +} + +fn png_signature_valid(bytes: []u8) i32 { + if (bytes.len < 8) { return 0; } + return (bytes[0] == 137 && bytes[1] == 80 && bytes[2] == 78 && + bytes[3] == 71 && bytes[4] == 13 && bytes[5] == 10 && + bytes[6] == 26 && bytes[7] == 10) as i32; +} + +fn png_chrm_is_srgb(bytes: []u8, offset: i32, length: i32) i32 { + if (length != 32) { return 0; } + return (png_read_u32_be(bytes, offset) == 31270 && + png_read_u32_be(bytes, offset + 4) == 32900 && + png_read_u32_be(bytes, offset + 8) == 64000 && + png_read_u32_be(bytes, offset + 12) == 33000 && + png_read_u32_be(bytes, offset + 16) == 30000 && + png_read_u32_be(bytes, offset + 20) == 60000 && + png_read_u32_be(bytes, offset + 24) == 15000 && + png_read_u32_be(bytes, offset + 28) == 6000) as i32; +} + +fn png_color_channels(color_type: i32) i32 { + if (color_type == 0 || color_type == 3) { return 1; } + if (color_type == 2) { return 3; } + if (color_type == 4) { return 2; } + if (color_type == 6) { return 4; } + return 0; +} + +fn png_bit_depth_valid(color_type: i32, bit_depth: i32) i32 { + if (color_type == 0) { + return (bit_depth == 1 || bit_depth == 2 || bit_depth == 4 || + bit_depth == 8 || bit_depth == 16) as i32; + } + if (color_type == 2 || color_type == 4 || color_type == 6) { + return (bit_depth == 8 || bit_depth == 16) as i32; + } + if (color_type == 3) { + return (bit_depth == 1 || bit_depth == 2 || bit_depth == 4 || + bit_depth == 8) as i32; + } + return 0; +} + +fn png_copy_append(output: *ArrayList[u8], bytes: []u8) void { + let i: i32 = 0; + while (i < bytes.len) { push[u8](output, bytes[i]); i = i + 1; } +} + +fn png_decode_parse_fail(idat: *ArrayList[u8], palette: *ArrayList[u8], + transparency: *ArrayList[u8], error: PngDecodeError, + offset: i64) PngDecodeResult { + free[u8](idat); free[u8](palette); free[u8](transparency); + return png_decode_fail(error, offset); +} + +fn png_paeth(a: i32, b: i32, c: i32) i32 { + let estimate: i32 = a + b - c; + let distance_a: i32 = estimate - a; + let distance_b: i32 = estimate - b; + let distance_c: i32 = estimate - c; + if (distance_a < 0) { distance_a = 0 - distance_a; } + if (distance_b < 0) { distance_b = 0 - distance_b; } + if (distance_c < 0) { distance_c = 0 - distance_c; } + if (distance_a <= distance_b && distance_a <= distance_c) { return a; } + if (distance_b <= distance_c) { return b; } + return c; +} + +fn png_unfilter(filtered: []u8, height: i32, row_bytes: i32, + bytes_per_pixel: i32) PngScanlineResult { + let output: ArrayList[u8] = make[u8](height * row_bytes); + let y: i32 = 0; + while (y < height) { + let source_row: i32 = y * (row_bytes + 1); + let filter: i32 = filtered[source_row] as i32; + if (filter < 0 || filter > 4) { + return PngScanlineResult{ + .bytes = output, .error = PngDecodeError.invalid_filter, + .error_offset = source_row as i64, + }; + } + let x: i32 = 0; + while (x < row_bytes) { + let encoded: i32 = filtered[source_row + 1 + x] as i32; + let left: i32 = 0; + let above: i32 = 0; + let upper_left: i32 = 0; + if (x >= bytes_per_pixel) { + left = output.data[y * row_bytes + x - bytes_per_pixel] as i32; + } + if (y > 0) { above = output.data[(y - 1) * row_bytes + x] as i32; } + if (y > 0 && x >= bytes_per_pixel) { + upper_left = output.data[(y - 1) * row_bytes + + x - bytes_per_pixel] as i32; + } + let predictor: i32 = 0; + if (filter == 1) { predictor = left; } + else if (filter == 2) { predictor = above; } + else if (filter == 3) { predictor = (left + above) / 2; } + else if (filter == 4) { predictor = png_paeth(left, above, upper_left); } + push[u8](&output, ((encoded + predictor) & 255) as u8); + x = x + 1; + } + y = y + 1; + } + return PngScanlineResult{ + .bytes = output, .error = PngDecodeError.none, .error_offset = 0 - 1, + }; +} + +fn png_sample(row: []u8, sample_index: i32, bit_depth: i32) i32 { + if (bit_depth == 8) { return row[sample_index] as i32; } + if (bit_depth == 16) { return png_read_u16_be(row, sample_index * 2); } + let bit_offset: i32 = sample_index * bit_depth; + let byte_index: i32 = bit_offset / 8; + let shift: i32 = 8 - bit_depth - (bit_offset % 8); + return ((row[byte_index] as i32) >> shift) & ((1 << bit_depth) - 1); +} + +fn png_sample_u8(sample: i32, bit_depth: i32) u8 { + if (bit_depth == 1) { return (sample * 255) as u8; } + if (bit_depth == 2) { return (sample * 85) as u8; } + if (bit_depth == 4) { return (sample * 17) as u8; } + if (bit_depth == 16) { return ((sample >> 8) & 255) as u8; } + return sample as u8; +} + +fn png_expand_rgba(raw: []u8, width: i32, height: i32, row_bytes: i32, + bit_depth: i32, color_type: i32, palette: ArrayList[u8], + transparency: ArrayList[u8], profile_assumed: i32) PngDecodeResult { + let pixels: ArrayList[u8] = make[u8](width * height * 4); + let transparent_gray: i32 = 0 - 1; + let transparent_red: i32 = 0 - 1; + let transparent_green: i32 = 0 - 1; + let transparent_blue: i32 = 0 - 1; + if (color_type == 0 && transparency.len == 2) { + transparent_gray = png_read_u16_be( + transparency.data[0..transparency.len], 0 + ); + } + if (color_type == 2 && transparency.len == 6) { + transparent_red = png_read_u16_be(transparency.data[0..transparency.len], 0); + transparent_green = png_read_u16_be(transparency.data[0..transparency.len], 2); + transparent_blue = png_read_u16_be(transparency.data[0..transparency.len], 4); + } + let y: i32 = 0; + while (y < height) { + let row: []u8 = raw[y * row_bytes..(y + 1) * row_bytes]; + let x: i32 = 0; + while (x < width) { + let red: u8 = 0; + let green: u8 = 0; + let blue: u8 = 0; + let alpha: u8 = 255; + if (color_type == 0) { + let gray: i32 = png_sample(row, x, bit_depth); + red = png_sample_u8(gray, bit_depth); + green = red; blue = red; + if (transparent_gray >= 0 && gray == transparent_gray) { alpha = 0; } + } else if (color_type == 2) { + let red_sample: i32 = png_sample(row, x * 3, bit_depth); + let green_sample: i32 = png_sample(row, x * 3 + 1, bit_depth); + let blue_sample: i32 = png_sample(row, x * 3 + 2, bit_depth); + red = png_sample_u8(red_sample, bit_depth); + green = png_sample_u8(green_sample, bit_depth); + blue = png_sample_u8(blue_sample, bit_depth); + if (red_sample == transparent_red && green_sample == transparent_green && + blue_sample == transparent_blue) { alpha = 0; } + } else if (color_type == 3) { + let index: i32 = png_sample(row, x, bit_depth); + if (index * 3 + 2 >= palette.len) { + free[u8](&pixels); + return png_decode_fail(PngDecodeError.palette_index, + (y * width + x) as i64); + } + red = palette.data[index * 3]; + green = palette.data[index * 3 + 1]; + blue = palette.data[index * 3 + 2]; + if (index < transparency.len) { alpha = transparency.data[index]; } + } else if (color_type == 4) { + let gray: i32 = png_sample(row, x * 2, bit_depth); + red = png_sample_u8(gray, bit_depth); + green = red; blue = red; + alpha = png_sample_u8(png_sample(row, x * 2 + 1, bit_depth), bit_depth); + } else { + red = png_sample_u8(png_sample(row, x * 4, bit_depth), bit_depth); + green = png_sample_u8(png_sample(row, x * 4 + 1, bit_depth), bit_depth); + blue = png_sample_u8(png_sample(row, x * 4 + 2, bit_depth), bit_depth); + alpha = png_sample_u8(png_sample(row, x * 4 + 3, bit_depth), bit_depth); + } + push[u8](&pixels, red); push[u8](&pixels, green); + push[u8](&pixels, blue); push[u8](&pixels, alpha); + x = x + 1; + } + y = y + 1; + } + return PngDecodeResult{ + .pixels = pixels, .width = width, .height = height, + .color_space = RenderColorSpace.srgb, + .profile_assumed = profile_assumed, + .error = PngDecodeError.none, .error_offset = 0 - 1, + }; +} + +fn png_decode(bytes: []u8) PngDecodeResult { + if (bytes.len as i64 > png_decode_input_limit()) { + return png_decode_fail(PngDecodeError.input_limit, bytes.len as i64); + } + if (bytes.len < 8) { return png_decode_fail(PngDecodeError.truncated, bytes.len as i64); } + if (png_signature_valid(bytes) == 0) { + return png_decode_fail(PngDecodeError.invalid_signature, 0); + } + + let idat: ArrayList[u8] = make[u8](256); + let palette: ArrayList[u8] = make[u8](256); + let transparency: ArrayList[u8] = make[u8](256); + let width: i32 = 0; + let height: i32 = 0; + let bit_depth: i32 = 0; + let color_type: i32 = 0; + let saw_header: i32 = 0; + let saw_palette: i32 = 0; + let saw_transparency: i32 = 0; + let saw_idat: i32 = 0; + let idat_ended: i32 = 0; + let saw_end: i32 = 0; + let saw_srgb: i32 = 0; + let saw_gamma: i32 = 0; + let saw_chrm: i32 = 0; + let offset: i32 = 8; + + while (offset < bytes.len && saw_end == 0) { + if (offset + 12 > bytes.len) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.truncated, offset as i64); + } + let length64: i64 = png_read_u32_be(bytes, offset); + if (length64 > png_decode_input_limit() || + length64 > (bytes.len - offset - 12) as i64) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_chunk_length, offset as i64); + } + let length: i32 = length64 as i32; + let data_offset: i32 = offset + 8; + let crc_offset: i32 = data_offset + length; + if (png_chunk_type_valid(bytes, offset) == 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_chunk_type, offset as i64); + } + let actual_crc: i64 = checksum_crc32(bytes[offset + 4..crc_offset]); + if (actual_crc != png_read_u32_be(bytes, crc_offset)) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_chunk_crc, offset as i64); + } + + if (png_chunk_is(bytes, offset, "IHDR") != 0) { + if (saw_header != 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.duplicate_chunk, offset as i64); + } + if (offset != 8 || length != 13) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_chunk_order, offset as i64); + } + let width64: i64 = png_read_u32_be(bytes, data_offset); + let height64: i64 = png_read_u32_be(bytes, data_offset + 4); + if (width64 <= 0 || height64 <= 0 || + width64 > png_decode_dimension_limit() as i64 || + height64 > png_decode_dimension_limit() as i64) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.pixel_limit, data_offset as i64); + } + width = width64 as i32; height = height64 as i32; + bit_depth = bytes[data_offset + 8] as i32; + color_type = bytes[data_offset + 9] as i32; + if (png_color_channels(color_type) == 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.unsupported_color_type, (data_offset + 9) as i64); + } + if (png_bit_depth_valid(color_type, bit_depth) == 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.unsupported_bit_depth, (data_offset + 8) as i64); + } + if (bytes[data_offset + 10] != 0 || bytes[data_offset + 11] != 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_header, (data_offset + 10) as i64); + } + if (bytes[data_offset + 12] != 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.unsupported_interlace, (data_offset + 12) as i64); + } + saw_header = 1; + } else if (saw_header == 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_chunk_order, offset as i64); + } else if (png_chunk_is(bytes, offset, "PLTE") != 0) { + if (saw_palette != 0 || saw_idat != 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_chunk_order, offset as i64); + } + if (length == 0 || length > 768 || length % 3 != 0 || + color_type == 0 || color_type == 4) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_palette, data_offset as i64); + } + png_copy_append(&palette, bytes[data_offset..data_offset + length]); + saw_palette = 1; + } else if (png_chunk_is(bytes, offset, "tRNS") != 0) { + if (saw_transparency != 0 || saw_idat != 0 || color_type == 4 || + color_type == 6 || (color_type == 0 && length != 2) || + (color_type == 2 && length != 6) || + (color_type == 3 && (saw_palette == 0 || length == 0 || + length > palette.len / 3))) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_transparency, data_offset as i64); + } + if (color_type == 0 && bit_depth < 16 && + png_read_u16_be(bytes, data_offset) >= (1 << bit_depth)) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_transparency, data_offset as i64); + } + if (color_type == 2 && bit_depth == 8 && + (png_read_u16_be(bytes, data_offset) > 255 || + png_read_u16_be(bytes, data_offset + 2) > 255 || + png_read_u16_be(bytes, data_offset + 4) > 255)) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_transparency, data_offset as i64); + } + png_copy_append(&transparency, bytes[data_offset..data_offset + length]); + saw_transparency = 1; + } else if (png_chunk_is(bytes, offset, "IDAT") != 0) { + if (idat_ended != 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_chunk_order, offset as i64); + } + if (idat.len as i64 + length as i64 > png_decode_input_limit()) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.input_limit, data_offset as i64); + } + png_copy_append(&idat, bytes[data_offset..data_offset + length]); + saw_idat = 1; + } else if (png_chunk_is(bytes, offset, "IEND") != 0) { + if (length != 0 || saw_idat == 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_chunk_order, offset as i64); + } + saw_end = 1; + } else if (png_chunk_is(bytes, offset, "sRGB") != 0) { + if (saw_srgb != 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.duplicate_chunk, offset as i64); + } + if (saw_palette != 0 || saw_idat != 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_chunk_order, offset as i64); + } + if (length != 1 || bytes[data_offset] > 3) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.unsupported_color_profile, offset as i64); + } + saw_srgb = 1; + } else if (png_chunk_is(bytes, offset, "gAMA") != 0) { + if (saw_gamma != 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.duplicate_chunk, offset as i64); + } + if (saw_palette != 0 || saw_idat != 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_chunk_order, offset as i64); + } + if (length != 4 || png_read_u32_be(bytes, data_offset) != 45455) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.unsupported_color_profile, offset as i64); + } + saw_gamma = 1; + } else if (png_chunk_is(bytes, offset, "cHRM") != 0) { + if (saw_chrm != 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.duplicate_chunk, offset as i64); + } + if (saw_palette != 0 || saw_idat != 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_chunk_order, offset as i64); + } + if (png_chrm_is_srgb(bytes, data_offset, length) == 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.unsupported_color_profile, offset as i64); + } + saw_chrm = 1; + } else if (png_chunk_is(bytes, offset, "iCCP") != 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.unsupported_color_profile, offset as i64); + } else { + if ((bytes[offset + 4] & 32) == 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.unknown_critical_chunk, offset as i64); + } + } + if (saw_idat != 0 && png_chunk_is(bytes, offset, "IDAT") == 0 && + png_chunk_is(bytes, offset, "IEND") == 0) { idat_ended = 1; } + offset = crc_offset + 4; + } + + if (saw_end == 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.truncated, offset as i64); + } + if (offset != bytes.len) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.trailing_data, offset as i64); + } + if (color_type == 3 && saw_palette == 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.missing_palette, 8); + } + if (color_type == 3 && palette.len / 3 > (1 << bit_depth)) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_palette, 8); + } + if (saw_idat == 0 || idat.len == 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.missing_image_data, 8); + } + + let channels: i32 = png_color_channels(color_type); + let row_bits: i64 = (width as i64) * (channels as i64) * (bit_depth as i64); + let row_bytes64: i64 = (row_bits + 7) / 8; + let expected64: i64 = (row_bytes64 + 1) * (height as i64); + let pixel_bytes64: i64 = (width as i64) * (height as i64) * 4; + if (row_bytes64 <= 0 || expected64 <= 0 || + pixel_bytes64 > png_decode_pixel_byte_limit() || expected64 > 2147483647) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.pixel_limit, 8); + } + let inflated: ZlibInflateResult = zlib_inflate( + idat.data[0..idat.len], expected64 + ); + free[u8](&idat); + if (inflated.error != ZlibInflateError.none) { + zlib_inflate_free(&inflated); free[u8](&palette); free[u8](&transparency); + return png_decode_fail(PngDecodeError.compression_error, 8); + } + if (inflated.bytes.len as i64 != expected64) { + zlib_inflate_free(&inflated); free[u8](&palette); free[u8](&transparency); + return png_decode_fail(PngDecodeError.decompressed_size, inflated.bytes.len as i64); + } + let bytes_per_pixel: i32 = ((channels * bit_depth) + 7) / 8; + if (bytes_per_pixel < 1) { bytes_per_pixel = 1; } + let scanlines: PngScanlineResult = png_unfilter( + inflated.bytes.data[0..inflated.bytes.len], height, + row_bytes64 as i32, bytes_per_pixel + ); + zlib_inflate_free(&inflated); + if (scanlines.error != PngDecodeError.none) { + let error: PngDecodeError = scanlines.error; + let error_offset: i64 = scanlines.error_offset; + free[u8](&scanlines.bytes); free[u8](&palette); free[u8](&transparency); + return png_decode_fail(error, error_offset); + } + let result: PngDecodeResult = png_expand_rgba( + scanlines.bytes.data[0..scanlines.bytes.len], width, height, + row_bytes64 as i32, bit_depth, color_type, palette, transparency, + (saw_srgb == 0 && !(saw_gamma != 0 && saw_chrm != 0)) as i32 + ); + free[u8](&scanlines.bytes); free[u8](&palette); free[u8](&transparency); + return result; +} + +fn png_decode_resource_spec(decoded: PngDecodeResult, id: i64) RenderResourceSpec { + let spec: RenderResourceSpec = render_resource_spec( + id, RenderResourceKind.image, + decoded.pixels.data[0..decoded.pixels.len], image_resource_format_tag() + ); + spec.width = decoded.width as i64; + spec.height = decoded.height as i64; + spec.color_space = decoded.color_space; + return spec; +} diff --git a/tests/png_decode_contract.zag b/tests/png_decode_contract.zag new file mode 100644 index 0000000..87d3ec8 --- /dev/null +++ b/tests/png_decode_contract.zag @@ -0,0 +1,453 @@ +@import("../src/render/png_decode.zag") +@import("../src/render/png_encode.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn pixels_equal(decoded: PngDecodeResult, surface: CpuSurface) i32 { + if (decoded.pixels.len != surface.pixels.len) { return 0; } + let i: i32 = 0; + while (i < decoded.pixels.len) { + if (decoded.pixels.data[i] != surface.pixels.data[i]) { return 0; } + i = i + 1; + } + return 1; +} + +fn fixture_png(width: i32, height: i32, bit_depth: i32, color_type: i32, + scanlines: []u8, palette: []u8, transparency: []u8) PngEncodeResult { + let compressed: ZlibStoreEncodeResult = zlib_store_encode(scanlines); + let output: ArrayList[u8] = make[u8](compressed.bytes.len + 128 + + palette.len + transparency.len); + push[u8](&output, 137); push[u8](&output, 80); + push[u8](&output, 78); push[u8](&output, 71); + push[u8](&output, 13); push[u8](&output, 10); + push[u8](&output, 26); push[u8](&output, 10); + let header: ArrayList[u8] = make[u8](13); + png_write_u32_be(&header, width as i64); + png_write_u32_be(&header, height as i64); + push[u8](&header, bit_depth as u8); push[u8](&header, color_type as u8); + push[u8](&header, 0); push[u8](&header, 0); push[u8](&header, 0); + png_write_chunk(&output, "IHDR", header.data[0..header.len]); + free[u8](&header); + if (palette.len > 0) { png_write_chunk(&output, "PLTE", palette); } + if (transparency.len > 0) { png_write_chunk(&output, "tRNS", transparency); } + png_write_chunk(&output, "IDAT", compressed.bytes.data[0..compressed.bytes.len]); + png_write_chunk(&output, "IEND", ""); + zlib_store_encode_free(&compressed); + return PngEncodeResult{ .bytes = output, .error = PngEncodeError.none }; +} + +fn fixture_png_compressed(width: i32, height: i32, compressed: []u8) PngEncodeResult { + let output: ArrayList[u8] = make[u8](compressed.len + 64); + push[u8](&output, 137); push[u8](&output, 80); + push[u8](&output, 78); push[u8](&output, 71); + push[u8](&output, 13); push[u8](&output, 10); + push[u8](&output, 26); push[u8](&output, 10); + let header: ArrayList[u8] = make[u8](13); + png_write_u32_be(&header, width as i64); png_write_u32_be(&header, height as i64); + push[u8](&header, 8); push[u8](&header, 6); + push[u8](&header, 0); push[u8](&header, 0); push[u8](&header, 0); + png_write_chunk(&output, "IHDR", header.data[0..header.len]); + free[u8](&header); + png_write_chunk(&output, "IDAT", compressed); + png_write_chunk(&output, "IEND", ""); + return PngEncodeResult{ .bytes = output, .error = PngEncodeError.none }; +} + +fn fixture_filtered_rows(raw: ArrayList[u8], width: i32, height: i32, + bytes_per_pixel: i32) ArrayList[u8] { + let row_bytes: i32 = width * bytes_per_pixel; + let filtered: ArrayList[u8] = make[u8](raw.len + height); + let y: i32 = 0; + while (y < height) { + let filter: i32 = y % 5; + push[u8](&filtered, filter as u8); + let x: i32 = 0; + while (x < row_bytes) { + let value: i32 = raw.data[y * row_bytes + x] as i32; + let left: i32 = 0; + let above: i32 = 0; + let upper_left: i32 = 0; + if (x >= bytes_per_pixel) { + left = raw.data[y * row_bytes + x - bytes_per_pixel] as i32; + } + if (y > 0) { above = raw.data[(y - 1) * row_bytes + x] as i32; } + if (y > 0 && x >= bytes_per_pixel) { + upper_left = raw.data[(y - 1) * row_bytes + + x - bytes_per_pixel] as i32; + } + let predictor: i32 = 0; + if (filter == 1) { predictor = left; } + else if (filter == 2) { predictor = above; } + else if (filter == 3) { predictor = (left + above) / 2; } + else if (filter == 4) { predictor = png_paeth(left, above, upper_left); } + push[u8](&filtered, ((value - predictor) & 255) as u8); + x = x + 1; + } + y = y + 1; + } + return filtered; +} + +fn fixture_write_u32_be(bytes: *ArrayList[u8], offset: i32, value: i64) void { + bytes.*.data[offset] = ((value >> 24) & 255) as u8; + bytes.*.data[offset + 1] = ((value >> 16) & 255) as u8; + bytes.*.data[offset + 2] = ((value >> 8) & 255) as u8; + bytes.*.data[offset + 3] = (value & 255) as u8; +} + +fn fixture_rewrite_crc(bytes: *ArrayList[u8], chunk_offset: i32) void { + let length: i32 = png_read_u32_be( + bytes.*.data[0..bytes.*.len], chunk_offset + ) as i32; + let crc_offset: i32 = chunk_offset + 8 + length; + let crc: i64 = checksum_crc32( + bytes.*.data[chunk_offset + 4..crc_offset] + ); + fixture_write_u32_be(bytes, crc_offset, crc); +} + +fn fixture_profile_png(gamma_count: i32, chromaticity_kind: i32) PngEncodeResult { + let compressed: ZlibStoreEncodeResult = zlib_store_encode("\x00\x11\x22\x33\xff"); + let output: ArrayList[u8] = make[u8](compressed.bytes.len + 160); + push[u8](&output, 137); push[u8](&output, 80); + push[u8](&output, 78); push[u8](&output, 71); + push[u8](&output, 13); push[u8](&output, 10); + push[u8](&output, 26); push[u8](&output, 10); + let header: ArrayList[u8] = make[u8](13); + png_write_u32_be(&header, 1); png_write_u32_be(&header, 1); + push[u8](&header, 8); push[u8](&header, 6); + push[u8](&header, 0); push[u8](&header, 0); push[u8](&header, 0); + png_write_chunk(&output, "IHDR", header.data[0..header.len]); + free[u8](&header); + let gamma: ArrayList[u8] = make[u8](4); + png_write_u32_be(&gamma, 45455); + let gamma_index: i32 = 0; + while (gamma_index < gamma_count) { + png_write_chunk(&output, "gAMA", gamma.data[0..gamma.len]); + gamma_index = gamma_index + 1; + } + free[u8](&gamma); + if (chromaticity_kind != 0) { + let chromaticity: ArrayList[u8] = make[u8](32); + png_write_u32_be(&chromaticity, 31270); + png_write_u32_be(&chromaticity, 32900); + png_write_u32_be(&chromaticity, 64000); + png_write_u32_be(&chromaticity, 33000); + png_write_u32_be(&chromaticity, 30000); + png_write_u32_be(&chromaticity, 60000); + png_write_u32_be(&chromaticity, 15000); + png_write_u32_be(&chromaticity, 6000 + (chromaticity_kind - 1)); + png_write_chunk(&output, "cHRM", chromaticity.data[0..chromaticity.len]); + free[u8](&chromaticity); + } + png_write_chunk(&output, "IDAT", compressed.bytes.data[0..compressed.bytes.len]); + png_write_chunk(&output, "IEND", ""); + zlib_store_encode_free(&compressed); + return PngEncodeResult{ .bytes = output, .error = PngEncodeError.none }; +} + +fn round_trip_contract(state: *TestState) void { + let surface: CpuSurface = cpu_surface_make(3, 2); + let i: i32 = 0; + while (i < surface.pixels.len) { + surface.pixels.data[i] = ((i * 43 + 19) % 256) as u8; + i = i + 1; + } + let encoded: PngEncodeResult = png_encode_surface(surface); + let decoded: PngDecodeResult = png_decode( + encoded.bytes.data[0..encoded.bytes.len] + ); + expect(state, decoded.error == PngDecodeError.none && + decoded.width == 3 && decoded.height == 2 && + decoded.color_space == RenderColorSpace.srgb && + decoded.profile_assumed == 0 && + pixels_equal(decoded, surface) != 0, + "canonical screenshot PNG decodes to exact owned RGBA8 pixels"); + + let store: RenderResourceStore = render_resource_store_default(); + let spec: RenderResourceSpec = png_decode_resource_spec(decoded, 91); + expect(state, render_resource_add(&store, spec) == RenderResourceError.none && + store.resources.data[0].width == 3 && store.resources.data[0].height == 2 && + image_resource_validate(store.resources.data[0]) == ImagePayloadError.none, + "decoded PNG becomes a canonical owned image resource"); + render_resource_store_free(&store); + png_decode_free(&decoded); + png_encode_free(&encoded); + cpu_surface_free(&surface); +} + +fn filter_contract(state: *TestState) void { + let raw: ArrayList[u8] = make[u8](40); + let i: i32 = 0; + while (i < 40) { push[u8](&raw, ((i * 31 + 7) % 256) as u8); i = i + 1; } + let scanlines: ArrayList[u8] = fixture_filtered_rows(raw, 2, 5, 4); + let fixture: PngEncodeResult = fixture_png( + 2, 5, 8, 6, scanlines.data[0..scanlines.len], "", "" + ); + let decoded: PngDecodeResult = png_decode( + fixture.bytes.data[0..fixture.bytes.len] + ); + let exact: i32 = 1; + if (decoded.pixels.len != raw.len) { exact = 0; } + i = 0; + while (i < decoded.pixels.len && i < raw.len) { + if (decoded.pixels.data[i] != raw.data[i]) { exact = 0; } + i = i + 1; + } + expect(state, decoded.error == PngDecodeError.none && exact != 0, + "None, Sub, Up, Average, and Paeth filters reconstruct exact bytes"); + png_decode_free(&decoded); png_encode_free(&fixture); + free[u8](&scanlines); free[u8](&raw); +} + +fn color_contract(state: *TestState) void { + let gray_fixture: PngEncodeResult = fixture_png( + 8, 1, 1, 0, "\x00\xaa", "", "\x00\x00" + ); + let gray: PngDecodeResult = png_decode( + gray_fixture.bytes.data[0..gray_fixture.bytes.len] + ); + expect(state, gray.error == PngDecodeError.none && gray.pixels.len == 32 && + gray.profile_assumed != 0 && + gray.pixels.data[0] == 255 && gray.pixels.data[3] == 255 && + gray.pixels.data[4] == 0 && gray.pixels.data[7] == 0, + "packed one-bit grayscale expands and applies transparent gray"); + png_decode_free(&gray); png_encode_free(&gray_fixture); + + let indexed_fixture: PngEncodeResult = fixture_png( + 4, 1, 2, 3, "\x00\x1b", + "\xff\x00\x00\x00\xff\x00\x00\x00\xff\xff\xff\xff", + "\xff\x80\x40\x00" + ); + let indexed: PngDecodeResult = png_decode( + indexed_fixture.bytes.data[0..indexed_fixture.bytes.len] + ); + expect(state, indexed.error == PngDecodeError.none && indexed.pixels.len == 16 && + indexed.pixels.data[0] == 255 && indexed.pixels.data[3] == 255 && + indexed.pixels.data[5] == 255 && indexed.pixels.data[7] == 128 && + indexed.pixels.data[10] == 255 && indexed.pixels.data[11] == 64 && + indexed.pixels.data[12] == 255 && indexed.pixels.data[15] == 0, + "packed indexed color expands palette entries and per-index alpha"); + png_decode_free(&indexed); png_encode_free(&indexed_fixture); + + let rgb16_fixture: PngEncodeResult = fixture_png( + 1, 1, 16, 2, "\x00\x12\x34\x56\x78\x9a\xbc", "", + "\x12\x34\x56\x78\x9a\xbc" + ); + let rgb16: PngDecodeResult = png_decode( + rgb16_fixture.bytes.data[0..rgb16_fixture.bytes.len] + ); + expect(state, rgb16.error == PngDecodeError.none && rgb16.pixels.len == 4 && + rgb16.pixels.data[0] == 18 && rgb16.pixels.data[1] == 86 && + rgb16.pixels.data[2] == 154 && rgb16.pixels.data[3] == 0, + "RGB16 uses deterministic high-byte conversion and exact tRNS matching"); + png_decode_free(&rgb16); png_encode_free(&rgb16_fixture); + + let gray_alpha_fixture: PngEncodeResult = fixture_png( + 1, 1, 16, 4, "\x00\xab\xcd\x40\x80", "", "" + ); + let gray_alpha: PngDecodeResult = png_decode( + gray_alpha_fixture.bytes.data[0..gray_alpha_fixture.bytes.len] + ); + expect(state, gray_alpha.error == PngDecodeError.none && + gray_alpha.pixels.data[0] == 171 && gray_alpha.pixels.data[1] == 171 && + gray_alpha.pixels.data[2] == 171 && gray_alpha.pixels.data[3] == 64, + "grayscale-alpha16 expands to canonical RGBA8"); + png_decode_free(&gray_alpha); png_encode_free(&gray_alpha_fixture); + + let rgba16_fixture: PngEncodeResult = fixture_png( + 1, 1, 16, 6, "\x00\x10\x20\x30\x40\x50\x60\x70\x80", "", "" + ); + let rgba16: PngDecodeResult = png_decode( + rgba16_fixture.bytes.data[0..rgba16_fixture.bytes.len] + ); + expect(state, rgba16.error == PngDecodeError.none && + rgba16.pixels.data[0] == 16 && rgba16.pixels.data[1] == 48 && + rgba16.pixels.data[2] == 80 && rgba16.pixels.data[3] == 112, + "RGBA16 expands each channel with one documented conversion policy"); + png_decode_free(&rgba16); png_encode_free(&rgba16_fixture); +} + +fn color_profile_contract(state: *TestState) void { + let declared_fixture: PngEncodeResult = fixture_profile_png(1, 1); + let declared: PngDecodeResult = png_decode( + declared_fixture.bytes.data[0..declared_fixture.bytes.len] + ); + expect(state, declared.error == PngDecodeError.none && + declared.profile_assumed == 0 && declared.pixels.data[0] == 17 && + declared.pixels.data[1] == 34 && declared.pixels.data[2] == 51, + "exact sRGB gamma and chromaticities decode without an assumed profile"); + png_decode_free(&declared); png_encode_free(&declared_fixture); + + let gamma_only_fixture: PngEncodeResult = fixture_profile_png(1, 0); + let gamma_only: PngDecodeResult = png_decode( + gamma_only_fixture.bytes.data[0..gamma_only_fixture.bytes.len] + ); + expect(state, gamma_only.error == PngDecodeError.none && + gamma_only.profile_assumed != 0, + "gamma without declared primaries keeps the fallback-profile truth visible"); + png_decode_free(&gamma_only); png_encode_free(&gamma_only_fixture); + + let duplicate_fixture: PngEncodeResult = fixture_profile_png(2, 0); + let duplicate: PngDecodeResult = png_decode( + duplicate_fixture.bytes.data[0..duplicate_fixture.bytes.len] + ); + expect(state, duplicate.error == PngDecodeError.duplicate_chunk, + "duplicate color metadata fails before image decompression"); + png_decode_free(&duplicate); png_encode_free(&duplicate_fixture); + + let foreign_fixture: PngEncodeResult = fixture_profile_png(1, 2); + let foreign: PngDecodeResult = png_decode( + foreign_fixture.bytes.data[0..foreign_fixture.bytes.len] + ); + expect(state, foreign.error == PngDecodeError.unsupported_color_profile, + "unconverted chromaticities fail rather than being mislabeled sRGB"); + png_decode_free(&foreign); png_encode_free(&foreign_fixture); +} + +fn dynamic_inflate_contract(state: *TestState) void { + let compressed: []u8 = + "\x78\xda\xed\xd0\x4d\x2b\xc3\x71\x00\xc0\xf1\x7f\xcd\x43\x5b\xda\xf2\xb0\x9a\x30\x31\x99\x96\x95\xa1\xa1\xad\x96\x95\x45\x8b\x28\x5a\x91\x87\x3c\xd5\x6a\x21\x4b\x16\x59\x4d\x34\x69\x42\xab\x95\xa7\x3c\x64\x25\x2b\x8b\x16\x4d\x71\xe1\xa0\xe4\xb2\x8b\x94\x03\x17\x0e\x4a\x2e\x2e\x52\x5f\x2f\xc4\xef\xf3\x12\x3e\x92\x24\x49\xc8\xd2\x33\xc9\x50\x28\x91\xab\xd4\x64\xe5\x15\xa2\xca\xd7\x91\xa3\x35\xa0\xd6\x99\xd0\x54\xd4\x53\x60\xb4\xa1\xad\x76\x50\x52\xd7\x4a\x99\xb5\x13\x7d\x63\x0f\x06\xc7\x20\x46\xa7\x9b\xaa\xf6\x09\x6a\xba\xa6\x31\x77\xfb\x69\xe8\x5f\xc4\x3a\x1c\xc2\xe6\x0e\x63\x1f\xdb\xa4\xc9\xbb\x4f\xb3\xef\x08\xe7\x5c\x9c\xb6\xf9\x73\x3a\x82\x57\x74\x86\x6e\x71\xad\xdf\xd3\x1d\x49\xd1\xbb\xf5\xc4\xc0\xde\x0b\x43\xd1\x77\x46\x8f\x3f\x71\xc7\xbf\xf1\x24\x7e\x19\x4f\xca\x98\xbc\x56\x30\x75\x93\x8d\xef\x4e\xc3\xec\x43\x31\xfe\x54\x39\x81\x47\x23\x0b\xcf\xb5\x04\x5f\x2d\x2c\xbf\xd9\x59\xf9\x68\x61\xed\xab\x9d\xf0\xb7\x8b\xc8\x4f\x1f\x1b\x8c\xb0\x9d\xe6\x61\x57\xee\xe5\x40\x39\x43\x34\x37\xc0\x91\x66\x89\x58\xd1\x2a\x27\xa5\x11\x4e\xf5\x3b\x24\x2a\x0f\xb9\x30\xc5\xb8\x34\x9f\x71\x65\x49\x22\x89\x00\x11\x20\x02\x44\x80\x08\x10\x01\x22\x40\x04\x88\x00\x11\x20\x02\x44\x80\x08\x10\x01\xff\x2f\xe0\x0f\xd7\xee\xee\x79"; + let fixture: PngEncodeResult = fixture_png_compressed(64, 16, compressed); + let decoded: PngDecodeResult = png_decode(fixture.bytes.data[0..fixture.bytes.len]); + let exact: i32 = decoded.error == PngDecodeError.none && decoded.pixels.len == 4096; + let y: i32 = 0; + while (y < 16 && exact != 0) { + let x: i32 = 0; + while (x < 64) { + let at: i32 = (y * 64 + x) * 4; + if (decoded.pixels.data[at] != ((x * 3) % 256) as u8 || + decoded.pixels.data[at + 1] != ((x * 5) % 256) as u8 || + decoded.pixels.data[at + 2] != ((x * 7) % 256) as u8 || + decoded.pixels.data[at + 3] != 255) { exact = 0; } + x = x + 1; + } + y = y + 1; + } + expect(state, exact, + "PNG IDAT accepts independently generated dynamic-Huffman zlib streams"); + png_decode_free(&decoded); png_encode_free(&fixture); +} + +fn rejection_contract(state: *TestState) void { + let signature: PngDecodeResult = png_decode("not a png"); + expect(state, signature.error == PngDecodeError.invalid_signature, + "invalid signatures fail before chunk parsing"); + png_decode_free(&signature); + + let surface: CpuSurface = cpu_surface_make(1, 1); + let encoded: PngEncodeResult = png_encode_surface(surface); + encoded.bytes.data[29] = encoded.bytes.data[29] ^ 1; + let crc: PngDecodeResult = png_decode(encoded.bytes.data[0..encoded.bytes.len]); + expect(state, crc.error == PngDecodeError.invalid_chunk_crc, + "chunk corruption fails CRC validation before decompression"); + png_decode_free(&crc); + encoded.bytes.data[29] = encoded.bytes.data[29] ^ 1; + + push[u8](&encoded.bytes, 0); + let trailing: PngDecodeResult = png_decode(encoded.bytes.data[0..encoded.bytes.len]); + expect(state, trailing.error == PngDecodeError.trailing_data, + "bytes after IEND fail canonical full-file decoding"); + png_decode_free(&trailing); + encoded.bytes.len = encoded.bytes.len - 1; + + let every_prefix_rejected: i32 = 1; + let length: i32 = 0; + while (length < encoded.bytes.len) { + let prefix: PngDecodeResult = png_decode(encoded.bytes.data[0..length]); + if (prefix.error == PngDecodeError.none) { every_prefix_rejected = 0; } + png_decode_free(&prefix); + length = length + 1; + } + expect(state, every_prefix_rejected, + "every strict PNG prefix fails without reading beyond available bytes"); + + encoded.bytes.data[28] = 1; + fixture_rewrite_crc(&encoded.bytes, 8); + let interlace: PngDecodeResult = png_decode(encoded.bytes.data[0..encoded.bytes.len]); + expect(state, interlace.error == PngDecodeError.unsupported_interlace, + "Adam7 reports an explicit unavailable capability instead of misrendering"); + png_decode_free(&interlace); + encoded.bytes.data[28] = 0; + fixture_rewrite_crc(&encoded.bytes, 8); + + let iend_offset: i32 = encoded.bytes.len - 12; + encoded.bytes.data[iend_offset + 6] = 'n'; + fixture_rewrite_crc(&encoded.bytes, iend_offset); + let reserved_type: PngDecodeResult = png_decode( + encoded.bytes.data[0..encoded.bytes.len] + ); + expect(state, reserved_type.error == PngDecodeError.invalid_chunk_type, + "chunk type reserved bits fail even when the attacker repairs the CRC"); + png_decode_free(&reserved_type); + encoded.bytes.data[iend_offset + 6] = 'N'; + fixture_rewrite_crc(&encoded.bytes, iend_offset); + + fixture_write_u32_be(&encoded.bytes, 16, 9000); + fixture_rewrite_crc(&encoded.bytes, 8); + let oversized: PngDecodeResult = png_decode(encoded.bytes.data[0..encoded.bytes.len]); + expect(state, oversized.error == PngDecodeError.pixel_limit, + "oversized dimensions fail before allocation or decompression"); + png_decode_free(&oversized); + png_encode_free(&encoded); + cpu_surface_free(&surface); + + let bad_filter_fixture: PngEncodeResult = fixture_png( + 1, 1, 8, 6, "\x05\x00\x00\x00\xff", "", "" + ); + let bad_filter: PngDecodeResult = png_decode( + bad_filter_fixture.bytes.data[0..bad_filter_fixture.bytes.len] + ); + expect(state, bad_filter.error == PngDecodeError.invalid_filter, + "unknown scanline filters fail explicitly"); + png_decode_free(&bad_filter); png_encode_free(&bad_filter_fixture); + + let missing_palette_fixture: PngEncodeResult = fixture_png( + 1, 1, 1, 3, "\x00\x00", "", "" + ); + let missing_palette: PngDecodeResult = png_decode( + missing_palette_fixture.bytes.data[0..missing_palette_fixture.bytes.len] + ); + expect(state, missing_palette.error == PngDecodeError.missing_palette, + "indexed images require a palette before inflate"); + png_decode_free(&missing_palette); png_encode_free(&missing_palette_fixture); + + let bad_index_fixture: PngEncodeResult = fixture_png( + 1, 1, 1, 3, "\x00\x80", "\xff\x00\x00", "" + ); + let bad_index: PngDecodeResult = png_decode( + bad_index_fixture.bytes.data[0..bad_index_fixture.bytes.len] + ); + expect(state, bad_index.error == PngDecodeError.palette_index, + "palette indices outside declared entries fail without out-of-bounds access"); + png_decode_free(&bad_index); png_encode_free(&bad_index_fixture); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + round_trip_contract(&state); + filter_contract(&state); + color_contract(&state); + color_profile_contract(&state); + dynamic_inflate_contract(&state); + rejection_contract(&state); + _zag_print("PNG decode contract: pass="); + let passed: []u8 = _zag_i64_to_str(state.passed as i64); + _zag_print(passed); _zag_str_free(passed); + _zag_print(" fail="); + let failed: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_println(failed); _zag_str_free(failed); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 36f37fa..5bceeda 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -44,6 +44,8 @@ cd "$root" "$tmp/image-contract" "$znc" tests/png_encode_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/png-encode-contract" "$tmp/png-encode-contract" +"$znc" tests/png_decode_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/png-decode-contract" +"$tmp/png-decode-contract" "$znc" tools/render-headless-reference.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/render-headless-reference" "$tmp/render-headless-reference" "$tmp/reference-first.png" "$tmp/render-headless-reference" "$tmp/reference-second.png" @@ -64,4 +66,4 @@ printf 'Reference snapshot contract: pass=3 fail=0\n' "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths and images, display lists, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion)\n' +printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths and images, bounded PNG decode, display lists, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion)\n' From a61cbb377679e78c30285b9c663c6db31edf9377 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 12:54:48 -0700 Subject: [PATCH 032/103] Decode bounded Adam7 PNG images --- README.md | 14 ++- docs/architecture/images.md | 19 +-- src/render/png_decode.zag | 167 +++++++++++++++++++++++++- tests/png_decode_contract.zag | 217 +++++++++++++++++++++++++++++++++- 4 files changed, 394 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 1e9d931..e8ccba7 100644 --- a/README.md +++ b/README.md @@ -174,12 +174,14 @@ yet claim the complete snapshot runner or screenshot release gate. See the Encoded PNG assets now enter the same canonical image boundary through a bounded pure-Zag decoder. It validates chunk spelling, order, CRCs, complete termination, and exact decompressed size; supports stored, fixed, and dynamic -DEFLATE; reverses all five PNG filters; and expands legal noninterlaced -grayscale, RGB, indexed, grayscale-alpha, and RGBA bit depths with PLTE and -tRNS into owned sRGB RGBA8. Focused contracts pass identically on x86-64 and -ARM64/qemu, and an independent 240-file corpus covers every accepted color and -bit-depth family across five zlib strategies. Adam7, arbitrary ICC/chromaticity -conversion, scale goldens, and broader fuzzing remain open, so the `G3-PNG` +DEFLATE; reverses all five PNG filters; and expands legal grayscale, RGB, +indexed, grayscale-alpha, and RGBA bit depths with PLTE and tRNS into owned +sRGB RGBA8. Both noninterlaced rows and all seven Adam7 passes are supported. +Focused contracts pass identically on x86-64 and ARM64/qemu, an independent +240-file corpus covers every accepted color and bit-depth family across five +zlib strategies, and an independently encoded Adam7 image matches exact source +pixels. Arbitrary ICC/chromaticity conversion, scale goldens, and broader +fuzzing remain open, so the `G3-PNG` completion box remains deliberately unchecked. See the [image and PNG ingestion contract](docs/architecture/images.md). diff --git a/docs/architecture/images.md b/docs/architecture/images.md index fe85ce9..e0b8e95 100644 --- a/docs/architecture/images.md +++ b/docs/architecture/images.md @@ -39,11 +39,13 @@ stored/fixed/dynamic DEFLATE inflater with the exact expected scanline byte count as its output ceiling. The decoder reverses None, Sub, Up, Average, and Paeth filters. It accepts the -legal noninterlaced grayscale, RGB, indexed, grayscale-alpha, and RGBA bit-depth -combinations, including packed 1/2/4-bit samples, PLTE, and tRNS. Renderer-owned -output is always straight-alpha sRGB RGBA8. Sixteen-bit samples use their high -byte as the current deterministic conversion policy; transparency comparisons -use the original full sample before conversion. Files without explicit color +legal grayscale, RGB, indexed, grayscale-alpha, and RGBA bit-depth combinations, +including packed 1/2/4-bit samples, PLTE, and tRNS. Noninterlaced rows and all +seven bounded Adam7 passes reconstruct into the same canonical pixels. +Renderer-owned output is always straight-alpha sRGB RGBA8. Sixteen-bit samples +use their high byte as the current deterministic conversion policy; +transparency comparisons use the original full sample before conversion. Files +without explicit color metadata use Zagkit's documented sRGB fallback policy; this is an assumption recorded by the asset pipeline, not a claim that the source declared sRGB. An explicit `sRGB` chunk, or the exact standard sRGB `gAMA` plus `cHRM` pair, @@ -56,10 +58,9 @@ the decoder can be freed immediately after insertion. Dimension, encoded-data, decompressed-scanline, output-pixel, palette-index, filter, and arithmetic limits fail before out-of-bounds access. Explicit iCCP, non-sRGB chromaticity conversion, and non-sRGB gAMA values fail as unsupported -color profiles. -Adam7 interlace also fails explicitly rather than being rendered incorrectly. -Those unavailable paths keep `G3-PNG` open until profile conversion, Adam7, -scale suites, and broader malformed-input fuzzing land. +color profiles. Unknown interlace methods fail before decompression. These +unavailable paths keep `G3-PNG` open until general profile conversion, scale +suites, and broader malformed-input fuzzing land. Linear-light filtering, Display P3 conversion, wide-gamut surfaces, mipmapping, high-quality downsampling, image tiling, and GPU upload caches also remain diff --git a/src/render/png_decode.zag b/src/render/png_decode.zag index df4d364..831645f 100644 --- a/src/render/png_decode.zag +++ b/src/render/png_decode.zag @@ -204,6 +204,60 @@ fn png_unfilter(filtered: []u8, height: i32, row_bytes: i32, }; } +fn png_adam7_start_x(pass: i32) i32 { + if (pass == 1) { return 4; } + if (pass == 3) { return 2; } + if (pass == 5) { return 1; } + return 0; +} + +fn png_adam7_start_y(pass: i32) i32 { + if (pass == 2) { return 4; } + if (pass == 4) { return 2; } + if (pass == 6) { return 1; } + return 0; +} + +fn png_adam7_step_x(pass: i32) i32 { + if (pass <= 1) { return 8; } + if (pass <= 3) { return 4; } + if (pass <= 5) { return 2; } + return 1; +} + +fn png_adam7_step_y(pass: i32) i32 { + if (pass <= 1) { return 8; } + if (pass <= 3) { return 8 - ((pass - 2) * 4); } + if (pass <= 5) { return 4 - ((pass - 4) * 2); } + return 2; +} + +fn png_adam7_extent(size: i32, start: i32, step: i32) i32 { + if (size <= start) { return 0; } + return (size - start + step - 1) / step; +} + +fn png_adam7_expected_size(width: i32, height: i32, + channels: i32, bit_depth: i32) i64 { + let total: i64 = 0; + let pass: i32 = 0; + while (pass < 7) { + let pass_width: i32 = png_adam7_extent( + width, png_adam7_start_x(pass), png_adam7_step_x(pass) + ); + let pass_height: i32 = png_adam7_extent( + height, png_adam7_start_y(pass), png_adam7_step_y(pass) + ); + if (pass_width > 0 && pass_height > 0) { + let row_bits: i64 = (pass_width as i64) * + (channels as i64) * (bit_depth as i64); + total = total + (((row_bits + 7) / 8) + 1) * (pass_height as i64); + } + pass = pass + 1; + } + return total; +} + fn png_sample(row: []u8, sample_index: i32, bit_depth: i32) i32 { if (bit_depth == 8) { return row[sample_index] as i32; } if (bit_depth == 16) { return png_read_u16_be(row, sample_index * 2); } @@ -213,6 +267,104 @@ fn png_sample(row: []u8, sample_index: i32, bit_depth: i32) i32 { return ((row[byte_index] as i32) >> shift) & ((1 << bit_depth) - 1); } +fn png_write_packed_sample(row: []u8, sample_index: i32, + bit_depth: i32, sample: i32) void { + let bit_offset: i32 = sample_index * bit_depth; + let byte_index: i32 = bit_offset / 8; + let shift: i32 = 8 - bit_depth - (bit_offset % 8); + row[byte_index] = row[byte_index] | + ((sample << shift) & 255) as u8; +} + +fn png_deinterlace(filtered: []u8, width: i32, height: i32, + channels: i32, bit_depth: i32) PngScanlineResult { + let full_row_bits: i64 = (width as i64) * + (channels as i64) * (bit_depth as i64); + let full_row_bytes: i32 = ((full_row_bits + 7) / 8) as i32; + let output: ArrayList[u8] = make[u8](full_row_bytes * height); + let fill_index: i32 = 0; + while (fill_index < full_row_bytes * height) { + push[u8](&output, 0); fill_index = fill_index + 1; + } + let bytes_per_pixel: i32 = ((channels * bit_depth) + 7) / 8; + if (bytes_per_pixel < 1) { bytes_per_pixel = 1; } + let filtered_offset: i32 = 0; + let pass: i32 = 0; + while (pass < 7) { + let start_x: i32 = png_adam7_start_x(pass); + let start_y: i32 = png_adam7_start_y(pass); + let step_x: i32 = png_adam7_step_x(pass); + let step_y: i32 = png_adam7_step_y(pass); + let pass_width: i32 = png_adam7_extent(width, start_x, step_x); + let pass_height: i32 = png_adam7_extent(height, start_y, step_y); + if (pass_width > 0 && pass_height > 0) { + let pass_row_bits: i64 = (pass_width as i64) * + (channels as i64) * (bit_depth as i64); + let pass_row_bytes: i32 = ((pass_row_bits + 7) / 8) as i32; + let pass_filtered_size: i32 = (pass_row_bytes + 1) * pass_height; + let scanlines: PngScanlineResult = png_unfilter( + filtered[filtered_offset..filtered_offset + pass_filtered_size], + pass_height, pass_row_bytes, bytes_per_pixel + ); + if (scanlines.error != PngDecodeError.none) { + let error_offset: i64 = filtered_offset as i64 + + scanlines.error_offset; + free[u8](&output); + return PngScanlineResult{ + .bytes = scanlines.bytes, .error = scanlines.error, + .error_offset = error_offset, + }; + } + let pass_y: i32 = 0; + while (pass_y < pass_height) { + let destination_y: i32 = start_y + pass_y * step_y; + let source_row: []u8 = scanlines.bytes.data[ + pass_y * pass_row_bytes..(pass_y + 1) * pass_row_bytes + ]; + let destination_row: []u8 = output.data[ + destination_y * full_row_bytes.. + (destination_y + 1) * full_row_bytes + ]; + let pass_x: i32 = 0; + while (pass_x < pass_width) { + let destination_x: i32 = start_x + pass_x * step_x; + if (bit_depth < 8) { + png_write_packed_sample(destination_row, destination_x, + bit_depth, png_sample(source_row, pass_x, bit_depth)); + } else { + let pixel_bytes: i32 = channels * (bit_depth / 8); + let byte_index: i32 = 0; + while (byte_index < pixel_bytes) { + destination_row[destination_x * pixel_bytes + byte_index] = + source_row[pass_x * pixel_bytes + byte_index]; + byte_index = byte_index + 1; + } + } + pass_x = pass_x + 1; + } + pass_y = pass_y + 1; + } + free[u8](&scanlines.bytes); + filtered_offset = filtered_offset + pass_filtered_size; + } + pass = pass + 1; + } + return PngScanlineResult{ + .bytes = output, .error = PngDecodeError.none, .error_offset = 0 - 1, + }; +} + +fn png_reconstruct_scanlines(filtered: []u8, width: i32, height: i32, + row_bytes: i32, channels: i32, bit_depth: i32, + interlace: i32) PngScanlineResult { + let bytes_per_pixel: i32 = ((channels * bit_depth) + 7) / 8; + if (bytes_per_pixel < 1) { bytes_per_pixel = 1; } + if (interlace == 0) { + return png_unfilter(filtered, height, row_bytes, bytes_per_pixel); + } + return png_deinterlace(filtered, width, height, channels, bit_depth); +} + fn png_sample_u8(sample: i32, bit_depth: i32) u8 { if (bit_depth == 1) { return (sample * 255) as u8; } if (bit_depth == 2) { return (sample * 85) as u8; } @@ -314,6 +466,7 @@ fn png_decode(bytes: []u8) PngDecodeResult { let height: i32 = 0; let bit_depth: i32 = 0; let color_type: i32 = 0; + let interlace: i32 = 0; let saw_header: i32 = 0; let saw_palette: i32 = 0; let saw_transparency: i32 = 0; @@ -381,7 +534,8 @@ fn png_decode(bytes: []u8) PngDecodeResult { return png_decode_parse_fail(&idat, &palette, &transparency, PngDecodeError.invalid_header, (data_offset + 10) as i64); } - if (bytes[data_offset + 12] != 0) { + interlace = bytes[data_offset + 12] as i32; + if (interlace > 1) { return png_decode_parse_fail(&idat, &palette, &transparency, PngDecodeError.unsupported_interlace, (data_offset + 12) as i64); } @@ -522,6 +676,9 @@ fn png_decode(bytes: []u8) PngDecodeResult { let row_bits: i64 = (width as i64) * (channels as i64) * (bit_depth as i64); let row_bytes64: i64 = (row_bits + 7) / 8; let expected64: i64 = (row_bytes64 + 1) * (height as i64); + if (interlace != 0) { + expected64 = png_adam7_expected_size(width, height, channels, bit_depth); + } let pixel_bytes64: i64 = (width as i64) * (height as i64) * 4; if (row_bytes64 <= 0 || expected64 <= 0 || pixel_bytes64 > png_decode_pixel_byte_limit() || expected64 > 2147483647) { @@ -540,11 +697,9 @@ fn png_decode(bytes: []u8) PngDecodeResult { zlib_inflate_free(&inflated); free[u8](&palette); free[u8](&transparency); return png_decode_fail(PngDecodeError.decompressed_size, inflated.bytes.len as i64); } - let bytes_per_pixel: i32 = ((channels * bit_depth) + 7) / 8; - if (bytes_per_pixel < 1) { bytes_per_pixel = 1; } - let scanlines: PngScanlineResult = png_unfilter( - inflated.bytes.data[0..inflated.bytes.len], height, - row_bytes64 as i32, bytes_per_pixel + let scanlines: PngScanlineResult = png_reconstruct_scanlines( + inflated.bytes.data[0..inflated.bytes.len], width, height, + row_bytes64 as i32, channels, bit_depth, interlace ); zlib_inflate_free(&inflated); if (scanlines.error != PngDecodeError.none) { diff --git a/tests/png_decode_contract.zag b/tests/png_decode_contract.zag index 87d3ec8..3f7bc80 100644 --- a/tests/png_decode_contract.zag +++ b/tests/png_decode_contract.zag @@ -99,6 +99,76 @@ fn fixture_filtered_rows(raw: ArrayList[u8], width: i32, height: i32, return filtered; } +fn fixture_png_interlaced(width: i32, height: i32, bit_depth: i32, + color_type: i32, scanlines: []u8, palette: []u8, + transparency: []u8) PngEncodeResult { + let compressed: ZlibStoreEncodeResult = zlib_store_encode(scanlines); + let output: ArrayList[u8] = make[u8](compressed.bytes.len + 128 + + palette.len + transparency.len); + push[u8](&output, 137); push[u8](&output, 80); + push[u8](&output, 78); push[u8](&output, 71); + push[u8](&output, 13); push[u8](&output, 10); + push[u8](&output, 26); push[u8](&output, 10); + let header: ArrayList[u8] = make[u8](13); + png_write_u32_be(&header, width as i64); + png_write_u32_be(&header, height as i64); + push[u8](&header, bit_depth as u8); push[u8](&header, color_type as u8); + push[u8](&header, 0); push[u8](&header, 0); push[u8](&header, 1); + png_write_chunk(&output, "IHDR", header.data[0..header.len]); + free[u8](&header); + if (palette.len > 0) { png_write_chunk(&output, "PLTE", palette); } + if (transparency.len > 0) { png_write_chunk(&output, "tRNS", transparency); } + png_write_chunk(&output, "IDAT", compressed.bytes.data[0..compressed.bytes.len]); + png_write_chunk(&output, "IEND", ""); + zlib_store_encode_free(&compressed); + return PngEncodeResult{ .bytes = output, .error = PngEncodeError.none }; +} + +fn fixture_adam7_rgba8(width: i32, height: i32, + pixels: []u8) PngEncodeResult { + let filtered: ArrayList[u8] = make[u8](pixels.len + height * 7); + let pass: i32 = 0; + while (pass < 7) { + let start_x: i32 = png_adam7_start_x(pass); + let start_y: i32 = png_adam7_start_y(pass); + let step_x: i32 = png_adam7_step_x(pass); + let step_y: i32 = png_adam7_step_y(pass); + let pass_width: i32 = png_adam7_extent(width, start_x, step_x); + let pass_height: i32 = png_adam7_extent(height, start_y, step_y); + if (pass_width > 0 && pass_height > 0) { + let pass_pixels: ArrayList[u8] = make[u8](pass_width * pass_height * 4); + let pass_y: i32 = 0; + while (pass_y < pass_height) { + let pass_x: i32 = 0; + while (pass_x < pass_width) { + let source_x: i32 = start_x + pass_x * step_x; + let source_y: i32 = start_y + pass_y * step_y; + let source: i32 = (source_y * width + source_x) * 4; + let channel: i32 = 0; + while (channel < 4) { + push[u8](&pass_pixels, pixels[source + channel]); + channel = channel + 1; + } + pass_x = pass_x + 1; + } + pass_y = pass_y + 1; + } + let pass_filtered: ArrayList[u8] = fixture_filtered_rows( + pass_pixels, pass_width, pass_height, 4 + ); + png_copy_append(&filtered, + pass_filtered.data[0..pass_filtered.len]); + free[u8](&pass_filtered); free[u8](&pass_pixels); + } + pass = pass + 1; + } + let result: PngEncodeResult = fixture_png_interlaced( + width, height, 8, 6, filtered.data[0..filtered.len], "", "" + ); + free[u8](&filtered); + return result; +} + fn fixture_write_u32_be(bytes: *ArrayList[u8], offset: i32, value: i64) void { bytes.*.data[offset] = ((value >> 24) & 255) as u8; bytes.*.data[offset + 1] = ((value >> 16) & 255) as u8; @@ -316,6 +386,134 @@ fn color_profile_contract(state: *TestState) void { png_decode_free(&foreign); png_encode_free(&foreign_fixture); } +fn interlace_contract(state: *TestState) void { + let pixels: ArrayList[u8] = make[u8](17 * 17 * 4); + let y: i32 = 0; + while (y < 17) { + let x: i32 = 0; + while (x < 17) { + push[u8](&pixels, ((x * 17 + y * 3) & 255) as u8); + push[u8](&pixels, ((x * 5 + y * 29) & 255) as u8); + push[u8](&pixels, ((x * 41 + y * 7) & 255) as u8); + push[u8](&pixels, ((x * 11 + y * 13) & 255) as u8); + x = x + 1; + } + y = y + 1; + } + let fixture: PngEncodeResult = fixture_adam7_rgba8( + 17, 17, pixels.data[0..pixels.len] + ); + let decoded: PngDecodeResult = png_decode( + fixture.bytes.data[0..fixture.bytes.len] + ); + let exact: i32 = decoded.error == PngDecodeError.none && + decoded.pixels.len == pixels.len; + let i: i32 = 0; + while (i < decoded.pixels.len && i < pixels.len) { + if (decoded.pixels.data[i] != pixels.data[i]) { exact = 0; } + i = i + 1; + } + expect(state, exact, + "all seven Adam7 passes reconstruct exact RGBA8 pixels through every filter"); + png_decode_free(&decoded); png_encode_free(&fixture); free[u8](&pixels); + + let sweep_exact: i32 = 1; + let sweep_height: i32 = 1; + while (sweep_height <= 12 && sweep_exact != 0) { + let sweep_width: i32 = 1; + while (sweep_width <= 12 && sweep_exact != 0) { + let sweep_pixels: ArrayList[u8] = make[u8]( + sweep_width * sweep_height * 4 + ); + let sweep_index: i32 = 0; + while (sweep_index < sweep_width * sweep_height * 4) { + push[u8](&sweep_pixels, + ((sweep_index * 37 + sweep_width * 11 + sweep_height) & 255) as u8); + sweep_index = sweep_index + 1; + } + let sweep_fixture: PngEncodeResult = fixture_adam7_rgba8( + sweep_width, sweep_height, + sweep_pixels.data[0..sweep_pixels.len] + ); + let sweep_decoded: PngDecodeResult = png_decode( + sweep_fixture.bytes.data[0..sweep_fixture.bytes.len] + ); + if (sweep_decoded.error != PngDecodeError.none || + sweep_decoded.pixels.len != sweep_pixels.len) { sweep_exact = 0; } + sweep_index = 0; + while (sweep_index < sweep_decoded.pixels.len && + sweep_index < sweep_pixels.len && sweep_exact != 0) { + if (sweep_decoded.pixels.data[sweep_index] != + sweep_pixels.data[sweep_index]) { sweep_exact = 0; } + sweep_index = sweep_index + 1; + } + png_decode_free(&sweep_decoded); png_encode_free(&sweep_fixture); + free[u8](&sweep_pixels); sweep_width = sweep_width + 1; + } + sweep_height = sweep_height + 1; + } + expect(state, sweep_exact, + "144 Adam7 dimension combinations preserve empty-pass and edge geometry"); + + let packed: ArrayList[u8] = make[u8](128); + let pass: i32 = 0; + while (pass < 7) { + let start_x: i32 = png_adam7_start_x(pass); + let start_y: i32 = png_adam7_start_y(pass); + let step_x: i32 = png_adam7_step_x(pass); + let step_y: i32 = png_adam7_step_y(pass); + let pass_width: i32 = png_adam7_extent(9, start_x, step_x); + let pass_height: i32 = png_adam7_extent(9, start_y, step_y); + if (pass_width > 0 && pass_height > 0) { + let row_bytes: i32 = (pass_width + 7) / 8; + let pass_y: i32 = 0; + while (pass_y < pass_height) { + push[u8](&packed, 0); + let row: ArrayList[u8] = make[u8](row_bytes); + let fill: i32 = 0; + while (fill < row_bytes) { push[u8](&row, 0); fill = fill + 1; } + let pass_x: i32 = 0; + while (pass_x < pass_width) { + let source_x: i32 = start_x + pass_x * step_x; + let source_y: i32 = start_y + pass_y * step_y; + png_write_packed_sample(row.data[0..row.len], pass_x, 1, + (source_x + source_y) & 1); + pass_x = pass_x + 1; + } + png_copy_append(&packed, row.data[0..row.len]); + free[u8](&row); pass_y = pass_y + 1; + } + } + pass = pass + 1; + } + let packed_fixture: PngEncodeResult = fixture_png_interlaced( + 9, 9, 1, 0, packed.data[0..packed.len], "", "" + ); + let packed_decoded: PngDecodeResult = png_decode( + packed_fixture.bytes.data[0..packed_fixture.bytes.len] + ); + let packed_exact: i32 = packed_decoded.error == PngDecodeError.none && + packed_decoded.pixels.len == 9 * 9 * 4; + y = 0; + while (y < 9 && packed_exact != 0) { + let x: i32 = 0; + while (x < 9) { + let expected: u8 = (((x + y) & 1) * 255) as u8; + let at: i32 = (y * 9 + x) * 4; + if (packed_decoded.pixels.data[at] != expected || + packed_decoded.pixels.data[at + 1] != expected || + packed_decoded.pixels.data[at + 2] != expected || + packed_decoded.pixels.data[at + 3] != 255) { packed_exact = 0; } + x = x + 1; + } + y = y + 1; + } + expect(state, packed_exact, + "Adam7 deinterlaces packed sub-byte samples without losing bit identity"); + png_decode_free(&packed_decoded); png_encode_free(&packed_fixture); + free[u8](&packed); +} + fn dynamic_inflate_contract(state: *TestState) void { let compressed: []u8 = "\x78\xda\xed\xd0\x4d\x2b\xc3\x71\x00\xc0\xf1\x7f\xcd\x43\x5b\xda\xf2\xb0\x9a\x30\x31\x99\x96\x95\xa1\xa1\xad\x96\x95\x45\x8b\x28\x5a\x91\x87\x3c\xd5\x6a\x21\x4b\x16\x59\x4d\x34\x69\x42\xab\x95\xa7\x3c\x64\x25\x2b\x8b\x16\x4d\x71\xe1\xa0\xe4\xb2\x8b\x94\x03\x17\x0e\x4a\x2e\x2e\x52\x5f\x2f\xc4\xef\xf3\x12\x3e\x92\x24\x49\xc8\xd2\x33\xc9\x50\x28\x91\xab\xd4\x64\xe5\x15\xa2\xca\xd7\x91\xa3\x35\xa0\xd6\x99\xd0\x54\xd4\x53\x60\xb4\xa1\xad\x76\x50\x52\xd7\x4a\x99\xb5\x13\x7d\x63\x0f\x06\xc7\x20\x46\xa7\x9b\xaa\xf6\x09\x6a\xba\xa6\x31\x77\xfb\x69\xe8\x5f\xc4\x3a\x1c\xc2\xe6\x0e\x63\x1f\xdb\xa4\xc9\xbb\x4f\xb3\xef\x08\xe7\x5c\x9c\xb6\xf9\x73\x3a\x82\x57\x74\x86\x6e\x71\xad\xdf\xd3\x1d\x49\xd1\xbb\xf5\xc4\xc0\xde\x0b\x43\xd1\x77\x46\x8f\x3f\x71\xc7\xbf\xf1\x24\x7e\x19\x4f\xca\x98\xbc\x56\x30\x75\x93\x8d\xef\x4e\xc3\xec\x43\x31\xfe\x54\x39\x81\x47\x23\x0b\xcf\xb5\x04\x5f\x2d\x2c\xbf\xd9\x59\xf9\x68\x61\xed\xab\x9d\xf0\xb7\x8b\xc8\x4f\x1f\x1b\x8c\xb0\x9d\xe6\x61\x57\xee\xe5\x40\x39\x43\x34\x37\xc0\x91\x66\x89\x58\xd1\x2a\x27\xa5\x11\x4e\xf5\x3b\x24\x2a\x0f\xb9\x30\xc5\xb8\x34\x9f\x71\x65\x49\x22\x89\x00\x11\x20\x02\x44\x80\x08\x10\x01\x22\x40\x04\x88\x00\x11\x20\x02\x44\x80\x08\x10\x01\xff\x2f\xe0\x0f\xd7\xee\xee\x79"; @@ -373,11 +571,11 @@ fn rejection_contract(state: *TestState) void { expect(state, every_prefix_rejected, "every strict PNG prefix fails without reading beyond available bytes"); - encoded.bytes.data[28] = 1; + encoded.bytes.data[28] = 2; fixture_rewrite_crc(&encoded.bytes, 8); let interlace: PngDecodeResult = png_decode(encoded.bytes.data[0..encoded.bytes.len]); expect(state, interlace.error == PngDecodeError.unsupported_interlace, - "Adam7 reports an explicit unavailable capability instead of misrendering"); + "unknown PNG interlace methods fail before decompression"); png_decode_free(&interlace); encoded.bytes.data[28] = 0; fixture_rewrite_crc(&encoded.bytes, 8); @@ -413,6 +611,20 @@ fn rejection_contract(state: *TestState) void { "unknown scanline filters fail explicitly"); png_decode_free(&bad_filter); png_encode_free(&bad_filter_fixture); + let expanding: ZlibStoreEncodeResult = zlib_store_encode( + "\x00\x00\x00\x00\xff\x7f" + ); + let expansion_fixture: PngEncodeResult = fixture_png_compressed( + 1, 1, expanding.bytes.data[0..expanding.bytes.len] + ); + let expansion: PngDecodeResult = png_decode( + expansion_fixture.bytes.data[0..expansion_fixture.bytes.len] + ); + expect(state, expansion.error == PngDecodeError.compression_error, + "compressed expansion beyond exact scanline size hits the inflate ceiling"); + png_decode_free(&expansion); png_encode_free(&expansion_fixture); + zlib_store_encode_free(&expanding); + let missing_palette_fixture: PngEncodeResult = fixture_png( 1, 1, 1, 3, "\x00\x00", "", "" ); @@ -440,6 +652,7 @@ fn main() i32 { filter_contract(&state); color_contract(&state); color_profile_contract(&state); + interlace_contract(&state); dynamic_inflate_contract(&state); rejection_contract(&state); _zag_print("PNG decode contract: pass="); From 179dec933f8f5b2e3995ae99e07531fb983796d2 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 12:58:08 -0700 Subject: [PATCH 033/103] Add PNG scale golden coverage --- README.md | 5 +-- docs/architecture/images.md | 9 +++-- tests/png_decode_contract.zag | 63 +++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e8ccba7..05cbe42 100644 --- a/README.md +++ b/README.md @@ -180,8 +180,9 @@ sRGB RGBA8. Both noninterlaced rows and all seven Adam7 passes are supported. Focused contracts pass identically on x86-64 and ARM64/qemu, an independent 240-file corpus covers every accepted color and bit-depth family across five zlib strategies, and an independently encoded Adam7 image matches exact source -pixels. Arbitrary ICC/chromaticity conversion, scale goldens, and broader -fuzzing remain open, so the `G3-PNG` +pixels. End-to-end decoded-resource CPU goldens pass at 1x, 1.25x, 1.5x, 2x, +and 3x on both compiled architectures. Arbitrary ICC/chromaticity conversion +and broader fuzzing remain open, so the `G3-PNG` completion box remains deliberately unchecked. See the [image and PNG ingestion contract](docs/architecture/images.md). diff --git a/docs/architecture/images.md b/docs/architecture/images.md index e0b8e95..7e74b94 100644 --- a/docs/architecture/images.md +++ b/docs/architecture/images.md @@ -55,12 +55,17 @@ records the fallback as assumed. adapts a successful owned result to the normal copying resource-store API, so the decoder can be freed immediately after insertion. +The end-to-end CPU contract decodes an encoded alpha image, transfers it through +the canonical resource store, and compares full-surface hashes at 1x, 1.25x, +1.5x, 2x, and 3x. The same golden hashes pass on x86-64 and ARM64; this is +headless scale evidence, not native compositor or monitor evidence. + Dimension, encoded-data, decompressed-scanline, output-pixel, palette-index, filter, and arithmetic limits fail before out-of-bounds access. Explicit iCCP, non-sRGB chromaticity conversion, and non-sRGB gAMA values fail as unsupported color profiles. Unknown interlace methods fail before decompression. These -unavailable paths keep `G3-PNG` open until general profile conversion, scale -suites, and broader malformed-input fuzzing land. +unavailable paths keep `G3-PNG` open until general profile conversion and +broader malformed-input fuzzing land. Linear-light filtering, Display P3 conversion, wide-gamut surfaces, mipmapping, high-quality downsampling, image tiling, and GPU upload caches also remain diff --git a/tests/png_decode_contract.zag b/tests/png_decode_contract.zag index 3f7bc80..7488cc4 100644 --- a/tests/png_decode_contract.zag +++ b/tests/png_decode_contract.zag @@ -23,6 +23,20 @@ fn pixels_equal(decoded: PngDecodeResult, surface: CpuSurface) i32 { return 1; } +fn decoded_image_scene(decoded: PngDecodeResult, size: i32) DisplayList { + let list: DisplayList = display_list_make(); + let spec: RenderResourceSpec = png_decode_resource_spec(decoded, 701); + _ = display_list_add_resource(&list, spec); + let draw: DisplayOp = display_op(DisplayOpKind.draw_image, node_key(701)); + draw.resource_id = 701; + draw.bounds = rect(0, 0, + (size as i64) * unit_scale(), (size as i64) * unit_scale()); + draw.paint.alpha = 65535; + _ = display_list_push(&list, draw); + _ = display_list_seal(&list); + return list; +} + fn fixture_png(width: i32, height: i32, bit_depth: i32, color_type: i32, scanlines: []u8, palette: []u8, transparency: []u8) PngEncodeResult { let compressed: ZlibStoreEncodeResult = zlib_store_encode(scanlines); @@ -281,6 +295,54 @@ fn filter_contract(state: *TestState) void { free[u8](&scanlines); free[u8](&raw); } +fn scale_render_contract(state: *TestState) void { + let surface: CpuSurface = cpu_surface_make(4, 4); + let y: i32 = 0; + while (y < 4) { + let x: i32 = 0; + while (x < 4) { + let at: i32 = (y * 4 + x) * 4; + surface.pixels.data[at] = (x * 71 + y * 13) as u8; + surface.pixels.data[at + 1] = (x * 19 + y * 53) as u8; + surface.pixels.data[at + 2] = (x * 37 + y * 29) as u8; + surface.pixels.data[at + 3] = (64 + x * 31 + y * 17) as u8; + x = x + 1; + } + y = y + 1; + } + let encoded: PngEncodeResult = png_encode_surface(surface); + let decoded: PngDecodeResult = png_decode( + encoded.bytes.data[0..encoded.bytes.len] + ); + let exact: i32 = decoded.error == PngDecodeError.none; + let scale_index: i32 = 0; + while (scale_index < 5 && exact != 0) { + let size: i32 = 4; + if (scale_index == 1) { size = 5; } + else if (scale_index == 2) { size = 6; } + else if (scale_index == 3) { size = 8; } + else if (scale_index == 4) { size = 12; } + let expected_hash: i64 = 1974955247; + if (scale_index == 1) { expected_hash = 1140701745; } + else if (scale_index == 2) { expected_hash = 24860848; } + else if (scale_index == 3) { expected_hash = 989379801; } + else if (scale_index == 4) { expected_hash = 692663035; } + let list: DisplayList = decoded_image_scene(decoded, size); + let first: CpuRasterResult = cpu_rasterize(list, size, size); + let second: CpuRasterResult = cpu_rasterize(list, size, size); + let first_hash: i64 = cpu_surface_hash(first.surface); + let second_hash: i64 = cpu_surface_hash(second.surface); + if (first.error != CpuRasterError.none || + second.error != CpuRasterError.none || first_hash == 0 || + first_hash != second_hash || first_hash != expected_hash) { exact = 0; } + cpu_raster_free(&second); cpu_raster_free(&first); + display_list_free(&list); scale_index = scale_index + 1; + } + expect(state, exact, + "decoded PNG resources match CPU goldens at 1x 1.25x 1.5x 2x and 3x"); + png_decode_free(&decoded); png_encode_free(&encoded); cpu_surface_free(&surface); +} + fn color_contract(state: *TestState) void { let gray_fixture: PngEncodeResult = fixture_png( 8, 1, 1, 0, "\x00\xaa", "", "\x00\x00" @@ -650,6 +712,7 @@ fn main() i32 { let state: TestState = TestState{ .passed = 0, .failed = 0 }; round_trip_contract(&state); filter_contract(&state); + scale_render_contract(&state); color_contract(&state); color_profile_contract(&state); interlace_contract(&state); From 7c8a1389a0a09ddaf5b73881a3bf0f621fcdc263 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 13:13:11 -0700 Subject: [PATCH 034/103] Convert PNG gamma profiles and fuzz decoding --- README.md | 7 +- docs/architecture/images.md | 31 +++++-- src/render/png_decode.zag | 130 +++++++++++++++++++++++++++- tests/png_decode_contract.zag | 87 +++++++++++++++++-- tests/png_decode_fuzz.zag | 155 ++++++++++++++++++++++++++++++++++ tools/test-headless.sh | 2 + 6 files changed, 389 insertions(+), 23 deletions(-) create mode 100644 tests/png_decode_fuzz.zag diff --git a/README.md b/README.md index 05cbe42..2c13488 100644 --- a/README.md +++ b/README.md @@ -181,8 +181,11 @@ Focused contracts pass identically on x86-64 and ARM64/qemu, an independent 240-file corpus covers every accepted color and bit-depth family across five zlib strategies, and an independently encoded Adam7 image matches exact source pixels. End-to-end decoded-resource CPU goldens pass at 1x, 1.25x, 1.5x, 2x, -and 3x on both compiled architectures. Arbitrary ICC/chromaticity conversion -and broader fuzzing remain open, so the `G3-PNG` +and 3x on both compiled architectures. Non-sRGB gamma transfer curves now +convert deterministically to sRGB and eight full ramps match independent +references. A deterministic 40,000-input mutation harness plus 4,096 repeated +cleanup cycles passes on both architectures. Arbitrary ICC/chromaticity +conversion and coverage-guided sanitizer evidence remain open, so the `G3-PNG` completion box remains deliberately unchecked. See the [image and PNG ingestion contract](docs/architecture/images.md). diff --git a/docs/architecture/images.md b/docs/architecture/images.md index 7e74b94..57ffadc 100644 --- a/docs/architecture/images.md +++ b/docs/architecture/images.md @@ -45,12 +45,21 @@ seven bounded Adam7 passes reconstruct into the same canonical pixels. Renderer-owned output is always straight-alpha sRGB RGBA8. Sixteen-bit samples use their high byte as the current deterministic conversion policy; transparency comparisons use the original full sample before conversion. Files -without explicit color -metadata use Zagkit's documented sRGB fallback policy; this is an assumption +without explicit color metadata use Zagkit's documented sRGB fallback policy; +this is an assumption recorded by the asset pipeline, not a claim that the source declared sRGB. An -explicit `sRGB` chunk, or the exact standard sRGB `gAMA` plus `cHRM` pair, -records a declared profile. A matching gamma without declared primaries still -records the fallback as assumed. +explicit `sRGB` chunk records sRGB directly. The conventional 45455 `gAMA` +plus standard sRGB `cHRM` pair records a declared approximate profile and is +converted rather than mislabeled as the piecewise sRGB transfer. A gamma chunk +without declared primaries still records the fallback as assumed. All nonzero +[`gAMA`](https://www.w3.org/TR/png-3/#11gAMA) transfer curves are +converted into canonical sRGB by deterministic fixed-point log, power, and +sRGB-transfer operations. When `cHRM` is absent, that conversion explicitly +assumes sRGB primaries; an `sRGB` chunk takes precedence over accompanying +gamma metadata. A meaningless zero gamma is ignored without becoming declared +profile truth. Eight complete 256-value ramps match independently calculated +references exactly on both compiled architectures. + `png_decode_resource_spec` adapts a successful owned result to the normal copying resource-store API, so the decoder can be freed immediately after insertion. @@ -60,12 +69,18 @@ the canonical resource store, and compares full-surface hashes at 1x, 1.25x, 1.5x, 2x, and 3x. The same golden hashes pass on x86-64 and ARM64; this is headless scale evidence, not native compositor or monitor evidence. +The strict deterministic fuzz gate decodes 20,000 arbitrary byte streams, +20,000 structured mutations of a valid PNG, every strict seed prefix, and 4,096 +decode/free repetitions on both x86-64 and ARM64. Every result must preserve +success or failure ownership invariants. Coverage-guided sanitizer campaigns +and a larger published malformed corpus remain additional required evidence. + Dimension, encoded-data, decompressed-scanline, output-pixel, palette-index, filter, and arithmetic limits fail before out-of-bounds access. Explicit iCCP, -non-sRGB chromaticity conversion, and non-sRGB gAMA values fail as unsupported -color profiles. Unknown interlace methods fail before decompression. These +and non-sRGB chromaticity conversion still fail as unsupported color profiles. +Unknown interlace methods fail before decompression. These unavailable paths keep `G3-PNG` open until general profile conversion and -broader malformed-input fuzzing land. +coverage-guided malformed-input evidence land. Linear-light filtering, Display P3 conversion, wide-gamut surfaces, mipmapping, high-quality downsampling, image tiling, and GPU upload caches also remain diff --git a/src/render/png_decode.zag b/src/render/png_decode.zag index 831645f..104e2fb 100644 --- a/src/render/png_decode.zag +++ b/src/render/png_decode.zag @@ -373,10 +373,121 @@ fn png_sample_u8(sample: i32, bit_depth: i32) u8 { return sample as u8; } +fn png_q30_one() i64 { return 1073741824; } +fn png_q20_one() i64 { return 1048576; } + +fn png_mul_q30(left: i64, right: i64) i64 { + return (left * right + png_q30_one() / 2) / png_q30_one(); +} + +fn png_exp2_factor(index: i32) i64 { + if (index == 1) { return 1518500250; } + if (index == 2) { return 1276901417; } + if (index == 3) { return 1170923762; } + if (index == 4) { return 1121280436; } + if (index == 5) { return 1097253708; } + if (index == 6) { return 1085434106; } + if (index == 7) { return 1079572136; } + if (index == 8) { return 1076653033; } + if (index == 9) { return 1075196443; } + if (index == 10) { return 1074468888; } + if (index == 11) { return 1074105294; } + if (index == 12) { return 1073923544; } + if (index == 13) { return 1073832680; } + if (index == 14) { return 1073787251; } + if (index == 15) { return 1073764537; } + if (index == 16) { return 1073753181; } + if (index == 17) { return 1073747502; } + if (index == 18) { return 1073744663; } + if (index == 19) { return 1073743244; } + return 1073742534; +} + +fn png_log2_q20(value: i64) i64 { + if (value <= 0) { return 0 - (64 * png_q20_one()); } + let normalized: i64 = value; + let integer: i64 = 0; + while (normalized < png_q30_one()) { + normalized = normalized * 2; integer = integer - 1; + } + let fraction: i64 = 0; + let index: i32 = 1; + while (index <= 20) { + normalized = png_mul_q30(normalized, normalized); + if (normalized >= 2 * png_q30_one()) { + normalized = normalized / 2; + fraction = fraction | (1 << (20 - index)) as i64; + } + index = index + 1; + } + return integer * png_q20_one() + fraction; +} + +fn png_exp2_q30(value: i64) i64 { + let integer: i64 = value / png_q20_one(); + let fraction: i64 = value % png_q20_one(); + if (fraction < 0) { + fraction = fraction + png_q20_one(); integer = integer - 1; + } + let result: i64 = png_q30_one(); + let index: i32 = 1; + while (index <= 20) { + if ((fraction & (1 << (20 - index)) as i64) != 0) { + result = png_mul_q30(result, png_exp2_factor(index)); + } + index = index + 1; + } + if (integer < 0) { + let shift: i64 = 0 - integer; + if (shift >= 31) { return 0; } + return result >> shift; + } + if (integer > 0) { + if (integer >= 31) { return 2147483647; } + return result << integer; + } + return result; +} + +fn png_pow_q30(value: i64, exponent_q20: i64) i64 { + if (value <= 0) { return 0; } + if (value >= png_q30_one()) { return png_q30_one(); } + return png_exp2_q30((png_log2_q20(value) * exponent_q20) / + png_q20_one()); +} + +fn png_gamma_sample_to_srgb(sample: u8, gamma: i64) u8 { + if (sample == 0 || gamma <= 0) { return 0; } + if (sample == 255) { return 255; } + let encoded: i64 = ((sample as i64) * png_q30_one() + 127) / 255; + let decode_exponent: i64 = (100000 * png_q20_one() + gamma / 2) / gamma; + let linear: i64 = png_pow_q30(encoded, decode_exponent); + let srgb: i64 = 0; + if (linear <= 3361671) { + srgb = (linear * 1292 + 50) / 100; + } else { + let encoded_root: i64 = png_pow_q30(linear, 436907); + srgb = png_mul_q30(encoded_root, 1132797624) - 59055800; + } + if (srgb <= 0) { return 0; } + if (srgb >= png_q30_one()) { return 255; } + return ((srgb * 255 + png_q30_one() / 2) / png_q30_one()) as u8; +} + fn png_expand_rgba(raw: []u8, width: i32, height: i32, row_bytes: i32, bit_depth: i32, color_type: i32, palette: ArrayList[u8], - transparency: ArrayList[u8], profile_assumed: i32) PngDecodeResult { + transparency: ArrayList[u8], source_gamma: i64, + profile_assumed: i32) PngDecodeResult { let pixels: ArrayList[u8] = make[u8](width * height * 4); + let gamma_table: ArrayList[u8] = make[u8](256); + if (source_gamma != 0) { + let gamma_sample: i32 = 0; + while (gamma_sample < 256) { + push[u8](&gamma_table, + png_gamma_sample_to_srgb(gamma_sample as u8, source_gamma)); + gamma_sample = gamma_sample + 1; + } + } let transparent_gray: i32 = 0 - 1; let transparent_red: i32 = 0 - 1; let transparent_green: i32 = 0 - 1; @@ -417,7 +528,7 @@ fn png_expand_rgba(raw: []u8, width: i32, height: i32, row_bytes: i32, } else if (color_type == 3) { let index: i32 = png_sample(row, x, bit_depth); if (index * 3 + 2 >= palette.len) { - free[u8](&pixels); + free[u8](&gamma_table); free[u8](&pixels); return png_decode_fail(PngDecodeError.palette_index, (y * width + x) as i64); } @@ -436,12 +547,18 @@ fn png_expand_rgba(raw: []u8, width: i32, height: i32, row_bytes: i32, blue = png_sample_u8(png_sample(row, x * 4 + 2, bit_depth), bit_depth); alpha = png_sample_u8(png_sample(row, x * 4 + 3, bit_depth), bit_depth); } + if (gamma_table.len == 256) { + red = gamma_table.data[red as i32]; + green = gamma_table.data[green as i32]; + blue = gamma_table.data[blue as i32]; + } push[u8](&pixels, red); push[u8](&pixels, green); push[u8](&pixels, blue); push[u8](&pixels, alpha); x = x + 1; } y = y + 1; } + free[u8](&gamma_table); return PngDecodeResult{ .pixels = pixels, .width = width, .height = height, .color_space = RenderColorSpace.srgb, @@ -476,6 +593,7 @@ fn png_decode(bytes: []u8) PngDecodeResult { let saw_srgb: i32 = 0; let saw_gamma: i32 = 0; let saw_chrm: i32 = 0; + let source_gamma: i64 = 0; let offset: i32 = 8; while (offset < bytes.len && saw_end == 0) { @@ -618,10 +736,11 @@ fn png_decode(bytes: []u8) PngDecodeResult { return png_decode_parse_fail(&idat, &palette, &transparency, PngDecodeError.invalid_chunk_order, offset as i64); } - if (length != 4 || png_read_u32_be(bytes, data_offset) != 45455) { + if (length != 4) { return png_decode_parse_fail(&idat, &palette, &transparency, PngDecodeError.unsupported_color_profile, offset as i64); } + source_gamma = png_read_u32_be(bytes, data_offset); saw_gamma = 1; } else if (png_chunk_is(bytes, offset, "cHRM") != 0) { if (saw_chrm != 0) { @@ -708,10 +827,13 @@ fn png_decode(bytes: []u8) PngDecodeResult { free[u8](&scanlines.bytes); free[u8](&palette); free[u8](&transparency); return png_decode_fail(error, error_offset); } + let conversion_gamma: i64 = source_gamma; + if (saw_srgb != 0) { conversion_gamma = 0; } let result: PngDecodeResult = png_expand_rgba( scanlines.bytes.data[0..scanlines.bytes.len], width, height, row_bytes64 as i32, bit_depth, color_type, palette, transparency, - (saw_srgb == 0 && !(saw_gamma != 0 && saw_chrm != 0)) as i32 + conversion_gamma, + (saw_srgb == 0 && !(source_gamma != 0 && saw_chrm != 0)) as i32 ); free[u8](&scanlines.bytes); free[u8](&palette); free[u8](&transparency); return result; diff --git a/tests/png_decode_contract.zag b/tests/png_decode_contract.zag index 7488cc4..6b32e23 100644 --- a/tests/png_decode_contract.zag +++ b/tests/png_decode_contract.zag @@ -201,7 +201,8 @@ fn fixture_rewrite_crc(bytes: *ArrayList[u8], chunk_offset: i32) void { fixture_write_u32_be(bytes, crc_offset, crc); } -fn fixture_profile_png(gamma_count: i32, chromaticity_kind: i32) PngEncodeResult { +fn fixture_profile_png(gamma_count: i32, chromaticity_kind: i32, + gamma_value: i64, include_srgb: i32) PngEncodeResult { let compressed: ZlibStoreEncodeResult = zlib_store_encode("\x00\x11\x22\x33\xff"); let output: ArrayList[u8] = make[u8](compressed.bytes.len + 160); push[u8](&output, 137); push[u8](&output, 80); @@ -214,8 +215,9 @@ fn fixture_profile_png(gamma_count: i32, chromaticity_kind: i32) PngEncodeResult push[u8](&header, 0); push[u8](&header, 0); push[u8](&header, 0); png_write_chunk(&output, "IHDR", header.data[0..header.len]); free[u8](&header); + if (include_srgb != 0) { png_write_chunk(&output, "sRGB", "\x00"); } let gamma: ArrayList[u8] = make[u8](4); - png_write_u32_be(&gamma, 45455); + png_write_u32_be(&gamma, gamma_value); let gamma_index: i32 = 0; while (gamma_index < gamma_count) { png_write_chunk(&output, "gAMA", gamma.data[0..gamma.len]); @@ -412,17 +414,17 @@ fn color_contract(state: *TestState) void { } fn color_profile_contract(state: *TestState) void { - let declared_fixture: PngEncodeResult = fixture_profile_png(1, 1); + let declared_fixture: PngEncodeResult = fixture_profile_png(1, 1, 45455, 0); let declared: PngDecodeResult = png_decode( declared_fixture.bytes.data[0..declared_fixture.bytes.len] ); expect(state, declared.error == PngDecodeError.none && - declared.profile_assumed == 0 && declared.pixels.data[0] == 17 && - declared.pixels.data[1] == 34 && declared.pixels.data[2] == 51, - "exact sRGB gamma and chromaticities decode without an assumed profile"); + declared.profile_assumed == 0 && declared.pixels.data[0] == 9 && + declared.pixels.data[1] == 28 && declared.pixels.data[2] == 48, + "declared gamma and standard primaries convert without assumed profile truth"); png_decode_free(&declared); png_encode_free(&declared_fixture); - let gamma_only_fixture: PngEncodeResult = fixture_profile_png(1, 0); + let gamma_only_fixture: PngEncodeResult = fixture_profile_png(1, 0, 45455, 0); let gamma_only: PngDecodeResult = png_decode( gamma_only_fixture.bytes.data[0..gamma_only_fixture.bytes.len] ); @@ -431,7 +433,38 @@ fn color_profile_contract(state: *TestState) void { "gamma without declared primaries keeps the fallback-profile truth visible"); png_decode_free(&gamma_only); png_encode_free(&gamma_only_fixture); - let duplicate_fixture: PngEncodeResult = fixture_profile_png(2, 0); + let linear_fixture: PngEncodeResult = fixture_profile_png(1, 0, 100000, 0); + let linear: PngDecodeResult = png_decode( + linear_fixture.bytes.data[0..linear_fixture.bytes.len] + ); + expect(state, linear.error == PngDecodeError.none && + linear.profile_assumed != 0 && linear.pixels.data[0] == 73 && + linear.pixels.data[1] == 102 && linear.pixels.data[2] == 124 && + linear.pixels.data[3] == 255, + "linear-gamma samples convert deterministically into canonical sRGB"); + png_decode_free(&linear); png_encode_free(&linear_fixture); + + let zero_fixture: PngEncodeResult = fixture_profile_png(1, 0, 0, 0); + let zero: PngDecodeResult = png_decode( + zero_fixture.bytes.data[0..zero_fixture.bytes.len] + ); + expect(state, zero.error == PngDecodeError.none && + zero.profile_assumed != 0 && zero.pixels.data[0] == 17 && + zero.pixels.data[1] == 34 && zero.pixels.data[2] == 51, + "meaningless zero gamma is ignored while fallback truth stays visible"); + png_decode_free(&zero); png_encode_free(&zero_fixture); + + let precedence_fixture: PngEncodeResult = fixture_profile_png(1, 0, 100000, 1); + let precedence: PngDecodeResult = png_decode( + precedence_fixture.bytes.data[0..precedence_fixture.bytes.len] + ); + expect(state, precedence.error == PngDecodeError.none && + precedence.profile_assumed == 0 && precedence.pixels.data[0] == 17 && + precedence.pixels.data[1] == 34 && precedence.pixels.data[2] == 51, + "explicit sRGB takes precedence over accompanying gamma metadata"); + png_decode_free(&precedence); png_encode_free(&precedence_fixture); + + let duplicate_fixture: PngEncodeResult = fixture_profile_png(2, 0, 45455, 0); let duplicate: PngDecodeResult = png_decode( duplicate_fixture.bytes.data[0..duplicate_fixture.bytes.len] ); @@ -439,13 +472,49 @@ fn color_profile_contract(state: *TestState) void { "duplicate color metadata fails before image decompression"); png_decode_free(&duplicate); png_encode_free(&duplicate_fixture); - let foreign_fixture: PngEncodeResult = fixture_profile_png(1, 2); + let foreign_fixture: PngEncodeResult = fixture_profile_png(1, 2, 45455, 0); let foreign: PngDecodeResult = png_decode( foreign_fixture.bytes.data[0..foreign_fixture.bytes.len] ); expect(state, foreign.error == PngDecodeError.unsupported_color_profile, "unconverted chromaticities fail rather than being mislabeled sRGB"); png_decode_free(&foreign); png_encode_free(&foreign_fixture); + + let ramp: ArrayList[u8] = make[u8](256); + let sample: i32 = 0; + while (sample < 256) { + push[u8](&ramp, png_gamma_sample_to_srgb(sample as u8, 100000)); + sample = sample + 1; + } + let ramps_exact: i32 = checksum_crc32(ramp.data[0..ramp.len]) == 1696635244; + free[u8](&ramp); + let gamma_values: ArrayList[i64] = make[i64](7); + push[i64](&gamma_values, 45455); push[i64](&gamma_values, 70000); + push[i64](&gamma_values, 55000); + push[i64](&gamma_values, 35000); push[i64](&gamma_values, 220000); + push[i64](&gamma_values, 25000); push[i64](&gamma_values, 500000); + let expected_crcs: ArrayList[i64] = make[i64](7); + push[i64](&expected_crcs, 247783021); push[i64](&expected_crcs, 3885669558); + push[i64](&expected_crcs, 4144236837); + push[i64](&expected_crcs, 2933270737); push[i64](&expected_crcs, 2066708568); + push[i64](&expected_crcs, 726426539); push[i64](&expected_crcs, 3244028137); + let gamma_index: i32 = 0; + while (gamma_index < gamma_values.len && ramps_exact != 0) { + let values: ArrayList[u8] = make[u8](256); + sample = 0; + while (sample < 256) { + push[u8](&values, png_gamma_sample_to_srgb( + sample as u8, gamma_values.data[gamma_index] + )); + sample = sample + 1; + } + if (checksum_crc32(values.data[0..values.len]) != + expected_crcs.data[gamma_index]) { ramps_exact = 0; } + free[u8](&values); gamma_index = gamma_index + 1; + } + expect(state, ramps_exact, + "eight full gamma ramps match independent sRGB transfer references"); + free[i64](&expected_crcs); free[i64](&gamma_values); } fn interlace_contract(state: *TestState) void { diff --git a/tests/png_decode_fuzz.zag b/tests/png_decode_fuzz.zag new file mode 100644 index 0000000..bde75be --- /dev/null +++ b/tests/png_decode_fuzz.zag @@ -0,0 +1,155 @@ +@import("../src/render/png_decode.zag") +@import("../src/render/png_encode.zag") + +struct FuzzState { passed: i32, failed: i32 } + +fn fuzz_expect(state: *FuzzState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn fuzz_next(random: *i64) i64 { + random.* = (random.* * 1103515245 + 12345) & 2147483647; + return random.*; +} + +fn fuzz_copy(bytes: []u8) ArrayList[u8] { + let copy: ArrayList[u8] = make[u8](bytes.len); + let i: i32 = 0; + while (i < bytes.len) { push[u8](©, bytes[i]); i = i + 1; } + return copy; +} + +fn fuzz_result_valid(result: PngDecodeResult) i32 { + if (result.error == PngDecodeError.none) { + if (result.width <= 0 || result.height <= 0 || + result.color_space != RenderColorSpace.srgb || + result.error_offset != 0 - 1 || + (result.profile_assumed != 0 && result.profile_assumed != 1)) { return 0; } + let expected: i64 = (result.width as i64) * + (result.height as i64) * 4; + return (expected > 0 && result.pixels.len as i64 == expected) as i32; + } + return (result.width == 0 && result.height == 0 && + result.color_space == RenderColorSpace.none && + result.pixels.len == 0 && result.profile_assumed == 0) as i32; +} + +fn fuzz_seed_surface() CpuSurface { + let surface: CpuSurface = cpu_surface_make(8, 8); + let i: i32 = 0; + while (i < surface.pixels.len) { + surface.pixels.data[i] = ((i * 73 + 29) & 255) as u8; + i = i + 1; + } + return surface; +} + +fn arbitrary_bytes_contract(state: *FuzzState) void { + let random: i64 = 881726454; + let valid: i32 = 1; + let case_index: i32 = 0; + while (case_index < 20000 && valid != 0) { + let length: i32 = (fuzz_next(&random) % 513) as i32; + let bytes: ArrayList[u8] = make[u8](length); + let i: i32 = 0; + while (i < length) { + push[u8](&bytes, (fuzz_next(&random) & 255) as u8); + i = i + 1; + } + let result: PngDecodeResult = png_decode(bytes.data[0..bytes.len]); + if (fuzz_result_valid(result) == 0) { valid = 0; } + png_decode_free(&result); free[u8](&bytes); + case_index = case_index + 1; + } + fuzz_expect(state, valid != 0 && case_index == 20000, + "20000 deterministic arbitrary byte streams remain bounded and owned"); +} + +fn mutation_contract(state: *FuzzState) void { + let surface: CpuSurface = fuzz_seed_surface(); + let encoded: PngEncodeResult = png_encode_surface(surface); + let random: i64 = 19349663; + let valid: i32 = 1; + let accepted: i32 = 0; + let case_index: i32 = 0; + while (case_index < 20000 && valid != 0) { + let bytes: ArrayList[u8] = fuzz_copy( + encoded.bytes.data[0..encoded.bytes.len] + ); + let changes: i32 = 1 + (fuzz_next(&random) % 4) as i32; + let change: i32 = 0; + while (change < changes) { + let index: i32 = (fuzz_next(&random) % bytes.len) as i32; + let bit: i32 = (fuzz_next(&random) % 8) as i32; + bytes.data[index] = bytes.data[index] ^ (1 << bit) as u8; + change = change + 1; + } + if (case_index % 5 == 0) { + bytes.len = (fuzz_next(&random) % bytes.len) as i32; + } + let result: PngDecodeResult = png_decode(bytes.data[0..bytes.len]); + if (fuzz_result_valid(result) == 0) { valid = 0; } + if (result.error == PngDecodeError.none) { accepted = accepted + 1; } + png_decode_free(&result); free[u8](&bytes); + case_index = case_index + 1; + } + fuzz_expect(state, valid != 0 && case_index == 20000 && accepted < 100, + "20000 structured PNG mutations fail closed or retain canonical output"); + png_encode_free(&encoded); cpu_surface_free(&surface); +} + +fn prefix_and_repeat_contract(state: *FuzzState) void { + let surface: CpuSurface = fuzz_seed_surface(); + let encoded: PngEncodeResult = png_encode_surface(surface); + let valid: i32 = 1; + let length: i32 = 0; + while (length < encoded.bytes.len && valid != 0) { + let result: PngDecodeResult = png_decode(encoded.bytes.data[0..length]); + if (result.error == PngDecodeError.none || fuzz_result_valid(result) == 0) { + valid = 0; + } + png_decode_free(&result); length = length + 1; + } + fuzz_expect(state, valid != 0, + "every strict canonical prefix preserves failure-result ownership invariants"); + + let expected_crc: i64 = checksum_crc32( + surface.pixels.data[0..surface.pixels.len] + ); + let repeat: i32 = 0; + while (repeat < 4096 && valid != 0) { + let result: PngDecodeResult = png_decode( + encoded.bytes.data[0..encoded.bytes.len] + ); + if (fuzz_result_valid(result) == 0 || + result.error != PngDecodeError.none || + checksum_crc32(result.pixels.data[0..result.pixels.len]) != expected_crc) { + valid = 0; + } + png_decode_free(&result); repeat = repeat + 1; + } + fuzz_expect(state, valid != 0 && repeat == 4096, + "4096 decode and cleanup cycles preserve exact pixels without state leakage"); + png_encode_free(&encoded); cpu_surface_free(&surface); +} + +fn main() i32 { + let state: FuzzState = FuzzState{ .passed = 0, .failed = 0 }; + arbitrary_bytes_contract(&state); + mutation_contract(&state); + prefix_and_repeat_contract(&state); + _zag_print("PNG decode fuzz: pass="); + let passed: []u8 = _zag_i64_to_str(state.passed as i64); + _zag_print(passed); _zag_str_free(passed); + _zag_print(" fail="); + let failed: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_println(failed); _zag_str_free(failed); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 5bceeda..051e5f4 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -46,6 +46,8 @@ cd "$root" "$tmp/png-encode-contract" "$znc" tests/png_decode_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/png-decode-contract" "$tmp/png-decode-contract" +"$znc" tests/png_decode_fuzz.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/png-decode-fuzz" +"$tmp/png-decode-fuzz" "$znc" tools/render-headless-reference.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/render-headless-reference" "$tmp/render-headless-reference" "$tmp/reference-first.png" "$tmp/render-headless-reference" "$tmp/reference-second.png" From 3d32fad6fc6cbdccaa36009f5e60554ac644d4e9 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 13:54:44 -0700 Subject: [PATCH 035/103] Convert PNG chromaticities to canonical sRGB --- README.md | 11 +- docs/architecture/images.md | 26 +- docs/architecture/render-resources.md | 8 +- src/render/png_decode.zag | 344 +++++++++++++++++++++++++- tests/png_decode_contract.zag | 74 ++++-- 5 files changed, 415 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 2c13488..b88d874 100644 --- a/README.md +++ b/README.md @@ -181,11 +181,12 @@ Focused contracts pass identically on x86-64 and ARM64/qemu, an independent 240-file corpus covers every accepted color and bit-depth family across five zlib strategies, and an independently encoded Adam7 image matches exact source pixels. End-to-end decoded-resource CPU goldens pass at 1x, 1.25x, 1.5x, 2x, -and 3x on both compiled architectures. Non-sRGB gamma transfer curves now -convert deterministically to sRGB and eight full ramps match independent -references. A deterministic 40,000-input mutation harness plus 4,096 repeated -cleanup cycles passes on both architectures. Arbitrary ICC/chromaticity -conversion and coverage-guided sanitizer evidence remain open, so the `G3-PNG` +and 3x on both compiled architectures. Non-sRGB gamma transfer curves and +arbitrary valid `cHRM` primaries now convert deterministically to sRGB, including +Bradford white-point adaptation; eight full transfer ramps and Display P3/D50 +fixtures match independent references. A deterministic 40,000-input mutation +harness plus 4,096 repeated cleanup cycles passes on both architectures. ICC +profile conversion and coverage-guided sanitizer evidence remain open, so the `G3-PNG` completion box remains deliberately unchecked. See the [image and PNG ingestion contract](docs/architecture/images.md). diff --git a/docs/architecture/images.md b/docs/architecture/images.md index 57ffadc..ff538aa 100644 --- a/docs/architecture/images.md +++ b/docs/architecture/images.md @@ -48,17 +48,19 @@ transparency comparisons use the original full sample before conversion. Files without explicit color metadata use Zagkit's documented sRGB fallback policy; this is an assumption recorded by the asset pipeline, not a claim that the source declared sRGB. An -explicit `sRGB` chunk records sRGB directly. The conventional 45455 `gAMA` -plus standard sRGB `cHRM` pair records a declared approximate profile and is -converted rather than mislabeled as the piecewise sRGB transfer. A gamma chunk -without declared primaries still records the fallback as assumed. All nonzero +explicit `sRGB` chunk records sRGB directly. Valid `cHRM` primaries are converted +through a bounded linear RGB-to-XYZ matrix, Bradford white-point adaptation, +and the canonical linear-sRGB matrix. Singular, non-finite, or out-of-domain +chromaticities fail before decompression. A gamma chunk without declared +primaries still records the fallback as assumed. All nonzero [`gAMA`](https://www.w3.org/TR/png-3/#11gAMA) transfer curves are converted into canonical sRGB by deterministic fixed-point log, power, and sRGB-transfer operations. When `cHRM` is absent, that conversion explicitly assumes sRGB primaries; an `sRGB` chunk takes precedence over accompanying -gamma metadata. A meaningless zero gamma is ignored without becoming declared -profile truth. Eight complete 256-value ramps match independently calculated -references exactly on both compiled architectures. +gamma and chromaticity metadata. A meaningless zero gamma is ignored without +becoming declared profile truth. Eight complete 256-value ramps plus Display P3 +and non-D65 fixtures match independently calculated references exactly on both +compiled architectures. `png_decode_resource_spec` adapts a successful owned result to the normal copying resource-store API, so @@ -76,12 +78,12 @@ success or failure ownership invariants. Coverage-guided sanitizer campaigns and a larger published malformed corpus remain additional required evidence. Dimension, encoded-data, decompressed-scanline, output-pixel, palette-index, -filter, and arithmetic limits fail before out-of-bounds access. Explicit iCCP, -and non-sRGB chromaticity conversion still fail as unsupported color profiles. -Unknown interlace methods fail before decompression. These -unavailable paths keep `G3-PNG` open until general profile conversion and +filter, color-matrix, and arithmetic limits fail before out-of-bounds access. +Explicit iCCP still fails as an unsupported color profile. Unknown interlace +methods fail before decompression. This unavailable path keeps `G3-PNG` open +until ICC profile conversion and coverage-guided malformed-input evidence land. -Linear-light filtering, Display P3 conversion, wide-gamut surfaces, mipmapping, +Linear-light filtering, wide-gamut output surfaces, mipmapping, high-quality downsampling, image tiling, and GPU upload caches also remain unavailable. diff --git a/docs/architecture/render-resources.md b/docs/architecture/render-resources.md index 96c74fb..9222ba3 100644 --- a/docs/architecture/render-resources.md +++ b/docs/architecture/render-resources.md @@ -53,8 +53,10 @@ the display-list revision and content identity. `ZKDL` version 2 serializes the canonical resource section before operations and reconstructs it through the same bounded validation path; decoding and re-encoding are byte identical. -The current executable contract still does not decode SVG or PNG, validate font -tables, interpret glyph payload schemas, rasterize path strokes, convert wide -gamut images, cache platform uploads, or implement memory-pressure eviction. Those +The current executable contract decodes bounded PNG assets into canonical owned +RGBA8 resources, including arbitrary valid `cHRM` conversion, but does not yet +decode SVG, execute ICC profiles, validate font tables, interpret glyph payload +schemas, rasterize path strokes, target wide-gamut output surfaces, cache +platform uploads, or implement memory-pressure eviction. Those capabilities remain unavailable until their own malformed-input, rendering, replacement, and cleanup suites pass. diff --git a/src/render/png_decode.zag b/src/render/png_decode.zag index 104e2fb..77b44a4 100644 --- a/src/render/png_decode.zag +++ b/src/render/png_decode.zag @@ -47,6 +47,24 @@ struct PngScanlineResult { error_offset: i64, } +struct PngMatrixF64 { + m00: f64, m01: f64, m02: f64, + m10: f64, m11: f64, m12: f64, + m20: f64, m21: f64, m22: f64, +} + +struct PngMatrixF64Result { matrix: PngMatrixF64, valid: i32 } + +struct PngVectorF64 { x: f64, y: f64, z: f64 } + +struct PngColorTransform { + m00: i64, m01: i64, m02: i64, + m10: i64, m11: i64, m12: i64, + m20: i64, m21: i64, m22: i64, + active: i32, + valid: i32, +} + fn png_decode_input_limit() i64 { return 134217728; } fn png_decode_pixel_byte_limit() i64 { return 67108864; } fn png_decode_dimension_limit() i32 { return 8192; } @@ -114,6 +132,242 @@ fn png_chrm_is_srgb(bytes: []u8, offset: i32, length: i32) i32 { png_read_u32_be(bytes, offset + 28) == 6000) as i32; } +fn png_matrix_zero() PngMatrixF64 { + return PngMatrixF64{ + .m00 = 0.0, .m01 = 0.0, .m02 = 0.0, + .m10 = 0.0, .m11 = 0.0, .m12 = 0.0, + .m20 = 0.0, .m21 = 0.0, .m22 = 0.0, + }; +} + +fn png_matrix_identity() PngMatrixF64 { + return PngMatrixF64{ + .m00 = 1.0, .m01 = 0.0, .m02 = 0.0, + .m10 = 0.0, .m11 = 1.0, .m12 = 0.0, + .m20 = 0.0, .m21 = 0.0, .m22 = 1.0, + }; +} + +fn png_matrix_multiply(left: PngMatrixF64, + right: PngMatrixF64) PngMatrixF64 { + return PngMatrixF64{ + .m00 = left.m00 * right.m00 + left.m01 * right.m10 + left.m02 * right.m20, + .m01 = left.m00 * right.m01 + left.m01 * right.m11 + left.m02 * right.m21, + .m02 = left.m00 * right.m02 + left.m01 * right.m12 + left.m02 * right.m22, + .m10 = left.m10 * right.m00 + left.m11 * right.m10 + left.m12 * right.m20, + .m11 = left.m10 * right.m01 + left.m11 * right.m11 + left.m12 * right.m21, + .m12 = left.m10 * right.m02 + left.m11 * right.m12 + left.m12 * right.m22, + .m20 = left.m20 * right.m00 + left.m21 * right.m10 + left.m22 * right.m20, + .m21 = left.m20 * right.m01 + left.m21 * right.m11 + left.m22 * right.m21, + .m22 = left.m20 * right.m02 + left.m21 * right.m12 + left.m22 * right.m22, + }; +} + +fn png_matrix_vector(matrix: PngMatrixF64, + vector: PngVectorF64) PngVectorF64 { + return PngVectorF64{ + .x = matrix.m00 * vector.x + matrix.m01 * vector.y + matrix.m02 * vector.z, + .y = matrix.m10 * vector.x + matrix.m11 * vector.y + matrix.m12 * vector.z, + .z = matrix.m20 * vector.x + matrix.m21 * vector.y + matrix.m22 * vector.z, + }; +} + +fn png_f64_abs(value: f64) f64 { + if (value < 0.0) { return 0.0 - value; } + return value; +} + +fn png_matrix_finite_bounded(matrix: PngMatrixF64, bound: f64) i32 { + if (matrix.m00 != matrix.m00 || matrix.m01 != matrix.m01 || + matrix.m02 != matrix.m02 || matrix.m10 != matrix.m10 || + matrix.m11 != matrix.m11 || matrix.m12 != matrix.m12 || + matrix.m20 != matrix.m20 || matrix.m21 != matrix.m21 || + matrix.m22 != matrix.m22) { return 0; } + return (png_f64_abs(matrix.m00) <= bound && + png_f64_abs(matrix.m01) <= bound && + png_f64_abs(matrix.m02) <= bound && + png_f64_abs(matrix.m10) <= bound && + png_f64_abs(matrix.m11) <= bound && + png_f64_abs(matrix.m12) <= bound && + png_f64_abs(matrix.m20) <= bound && + png_f64_abs(matrix.m21) <= bound && + png_f64_abs(matrix.m22) <= bound) as i32; +} + +fn png_matrix_inverse(matrix: PngMatrixF64) PngMatrixF64Result { + let determinant: f64 = matrix.m00 * + (matrix.m11 * matrix.m22 - matrix.m12 * matrix.m21) - + matrix.m01 * (matrix.m10 * matrix.m22 - matrix.m12 * matrix.m20) + + matrix.m02 * (matrix.m10 * matrix.m21 - matrix.m11 * matrix.m20); + if (png_f64_abs(determinant) < 0.000000000001) { + return PngMatrixF64Result{ .matrix = png_matrix_zero(), .valid = 0 }; + } + let inverse: f64 = 1.0 / determinant; + return PngMatrixF64Result{ + .matrix = PngMatrixF64{ + .m00 = (matrix.m11 * matrix.m22 - matrix.m12 * matrix.m21) * inverse, + .m01 = (matrix.m02 * matrix.m21 - matrix.m01 * matrix.m22) * inverse, + .m02 = (matrix.m01 * matrix.m12 - matrix.m02 * matrix.m11) * inverse, + .m10 = (matrix.m12 * matrix.m20 - matrix.m10 * matrix.m22) * inverse, + .m11 = (matrix.m00 * matrix.m22 - matrix.m02 * matrix.m20) * inverse, + .m12 = (matrix.m02 * matrix.m10 - matrix.m00 * matrix.m12) * inverse, + .m20 = (matrix.m10 * matrix.m21 - matrix.m11 * matrix.m20) * inverse, + .m21 = (matrix.m01 * matrix.m20 - matrix.m00 * matrix.m21) * inverse, + .m22 = (matrix.m00 * matrix.m11 - matrix.m01 * matrix.m10) * inverse, + }, + .valid = 1, + }; +} + +fn png_chromaticity_vector(x: i64, y: i64) PngVectorF64 { + let xf: f64 = x as f64 / 100000.0; + let yf: f64 = y as f64 / 100000.0; + return PngVectorF64{ + .x = xf / yf, .y = 1.0, .z = (1.0 - xf - yf) / yf, + }; +} + +fn png_rgb_to_xyz(red_x: i64, red_y: i64, green_x: i64, green_y: i64, + blue_x: i64, blue_y: i64, white_x: i64, + white_y: i64) PngMatrixF64Result { + let red: PngVectorF64 = png_chromaticity_vector(red_x, red_y); + let green: PngVectorF64 = png_chromaticity_vector(green_x, green_y); + let blue: PngVectorF64 = png_chromaticity_vector(blue_x, blue_y); + let primaries: PngMatrixF64 = PngMatrixF64{ + .m00 = red.x, .m01 = green.x, .m02 = blue.x, + .m10 = red.y, .m11 = green.y, .m12 = blue.y, + .m20 = red.z, .m21 = green.z, .m22 = blue.z, + }; + let inverse: PngMatrixF64Result = png_matrix_inverse(primaries); + if (inverse.valid == 0) { return inverse; } + let scale: PngVectorF64 = png_matrix_vector( + inverse.matrix, png_chromaticity_vector(white_x, white_y) + ); + return PngMatrixF64Result{ + .matrix = PngMatrixF64{ + .m00 = primaries.m00 * scale.x, + .m01 = primaries.m01 * scale.y, + .m02 = primaries.m02 * scale.z, + .m10 = primaries.m10 * scale.x, + .m11 = primaries.m11 * scale.y, + .m12 = primaries.m12 * scale.z, + .m20 = primaries.m20 * scale.x, + .m21 = primaries.m21 * scale.y, + .m22 = primaries.m22 * scale.z, + }, + .valid = 1, + }; +} + +fn png_quantize_matrix_value(value: f64) i64 { + if (value >= 0.0) { return (value * 16777216.0 + 0.5) as i64; } + return (value * 16777216.0 - 0.5) as i64; +} + +fn png_color_transform_identity() PngColorTransform { + return PngColorTransform{ + .m00 = 16777216, .m01 = 0, .m02 = 0, + .m10 = 0, .m11 = 16777216, .m12 = 0, + .m20 = 0, .m21 = 0, .m22 = 16777216, + .active = 0, .valid = 1, + }; +} + +fn png_chrm_values_valid(values: []i64) i32 { + if (values.len != 8) { return 0; } + let pair: i32 = 0; + while (pair < 4) { + let x: i64 = values[pair * 2]; + let y: i64 = values[pair * 2 + 1]; + if (x <= 0 || y <= 0 || x >= 100000 || y >= 100000 || + x + y > 100000) { return 0; } + pair = pair + 1; + } + return 1; +} + +fn png_chrm_transform(bytes: []u8, offset: i32, + length: i32) PngColorTransform { + if (length != 32) { + let invalid: PngColorTransform = png_color_transform_identity(); + invalid.valid = 0; return invalid; + } + if (png_chrm_is_srgb(bytes, offset, length) != 0) { + return png_color_transform_identity(); + } + let values: ArrayList[i64] = make[i64](8); + let index: i32 = 0; + while (index < 8) { + push[i64](&values, png_read_u32_be(bytes, offset + index * 4)); + index = index + 1; + } + if (png_chrm_values_valid(values.data[0..values.len]) == 0) { + free[i64](&values); + let invalid: PngColorTransform = png_color_transform_identity(); + invalid.valid = 0; return invalid; + } + let source: PngMatrixF64Result = png_rgb_to_xyz( + values.data[2], values.data[3], values.data[4], values.data[5], + values.data[6], values.data[7], values.data[0], values.data[1] + ); + let target: PngMatrixF64Result = png_rgb_to_xyz( + 64000, 33000, 30000, 60000, 15000, 6000, 31270, 32900 + ); + let bradford: PngMatrixF64 = PngMatrixF64{ + .m00 = 0.8951, .m01 = 0.2664, .m02 = -0.1614, + .m10 = -0.7502, .m11 = 1.7135, .m12 = 0.0367, + .m20 = 0.0389, .m21 = -0.0685, .m22 = 1.0296, + }; + let bradford_inverse: PngMatrixF64Result = png_matrix_inverse(bradford); + let target_inverse: PngMatrixF64Result = png_matrix_inverse(target.matrix); + if (source.valid == 0 || target.valid == 0 || + bradford_inverse.valid == 0 || target_inverse.valid == 0) { + free[i64](&values); + let invalid: PngColorTransform = png_color_transform_identity(); + invalid.valid = 0; return invalid; + } + let source_white: PngVectorF64 = png_chromaticity_vector( + values.data[0], values.data[1] + ); + let target_white: PngVectorF64 = png_chromaticity_vector(31270, 32900); + free[i64](&values); + let source_cone: PngVectorF64 = png_matrix_vector(bradford, source_white); + let target_cone: PngVectorF64 = png_matrix_vector(bradford, target_white); + if (png_f64_abs(source_cone.x) < 0.000000000001 || + png_f64_abs(source_cone.y) < 0.000000000001 || + png_f64_abs(source_cone.z) < 0.000000000001) { + let invalid: PngColorTransform = png_color_transform_identity(); + invalid.valid = 0; return invalid; + } + let cone_scale: PngMatrixF64 = PngMatrixF64{ + .m00 = target_cone.x / source_cone.x, .m01 = 0.0, .m02 = 0.0, + .m10 = 0.0, .m11 = target_cone.y / source_cone.y, .m12 = 0.0, + .m20 = 0.0, .m21 = 0.0, .m22 = target_cone.z / source_cone.z, + }; + let adaptation: PngMatrixF64 = png_matrix_multiply( + bradford_inverse.matrix, png_matrix_multiply(cone_scale, bradford) + ); + let final_matrix: PngMatrixF64 = png_matrix_multiply( + target_inverse.matrix, png_matrix_multiply(adaptation, source.matrix) + ); + if (png_matrix_finite_bounded(final_matrix, 8.0) == 0) { + let invalid: PngColorTransform = png_color_transform_identity(); + invalid.valid = 0; return invalid; + } + return PngColorTransform{ + .m00 = png_quantize_matrix_value(final_matrix.m00), + .m01 = png_quantize_matrix_value(final_matrix.m01), + .m02 = png_quantize_matrix_value(final_matrix.m02), + .m10 = png_quantize_matrix_value(final_matrix.m10), + .m11 = png_quantize_matrix_value(final_matrix.m11), + .m12 = png_quantize_matrix_value(final_matrix.m12), + .m20 = png_quantize_matrix_value(final_matrix.m20), + .m21 = png_quantize_matrix_value(final_matrix.m21), + .m22 = png_quantize_matrix_value(final_matrix.m22), + .active = 1, .valid = 1, + }; +} + fn png_color_channels(color_type: i32) i32 { if (color_type == 0 || color_type == 3) { return 1; } if (color_type == 2) { return 3; } @@ -456,12 +710,10 @@ fn png_pow_q30(value: i64, exponent_q20: i64) i64 { png_q20_one()); } -fn png_gamma_sample_to_srgb(sample: u8, gamma: i64) u8 { - if (sample == 0 || gamma <= 0) { return 0; } - if (sample == 255) { return 255; } - let encoded: i64 = ((sample as i64) * png_q30_one() + 127) / 255; - let decode_exponent: i64 = (100000 * png_q20_one() + gamma / 2) / gamma; - let linear: i64 = png_pow_q30(encoded, decode_exponent); +fn png_linear_q30_to_srgb(linear_input: i64) u8 { + let linear: i64 = linear_input; + if (linear <= 0) { return 0; } + if (linear >= png_q30_one()) { return 255; } let srgb: i64 = 0; if (linear <= 3361671) { srgb = (linear * 1292 + 50) / 100; @@ -474,13 +726,57 @@ fn png_gamma_sample_to_srgb(sample: u8, gamma: i64) u8 { return ((srgb * 255 + png_q30_one() / 2) / png_q30_one()) as u8; } +fn png_srgb_sample_to_linear_q30(sample: u8) i64 { + if (sample == 0) { return 0; } + if (sample == 255) { return png_q30_one(); } + let encoded: i64 = ((sample as i64) * png_q30_one() + 127) / 255; + if (encoded <= 43432857) { + return (encoded * 100 + 646) / 1292; + } + let adjusted: i64 = ((encoded + 59055800) * png_q30_one() + + 1132797624 / 2) / 1132797624; + return png_pow_q30(adjusted, 2516582); +} + +fn png_source_sample_to_linear_q30(sample: u8, gamma: i64) i64 { + if (sample == 0) { return 0; } + if (sample == 255) { return png_q30_one(); } + if (gamma == 0) { return png_srgb_sample_to_linear_q30(sample); } + let encoded: i64 = ((sample as i64) * png_q30_one() + 127) / 255; + let decode_exponent: i64 = (100000 * png_q20_one() + gamma / 2) / gamma; + return png_pow_q30(encoded, decode_exponent); +} + +fn png_gamma_sample_to_srgb(sample: u8, gamma: i64) u8 { + if (sample == 0 || gamma <= 0) { return 0; } + if (sample == 255) { return 255; } + return png_linear_q30_to_srgb( + png_source_sample_to_linear_q30(sample, gamma) + ); +} + +fn png_transform_linear_channel(a: i64, b: i64, c: i64, + red: i64, green: i64, blue: i64) i64 { + return (a * red + b * green + c * blue) / 16777216; +} + fn png_expand_rgba(raw: []u8, width: i32, height: i32, row_bytes: i32, bit_depth: i32, color_type: i32, palette: ArrayList[u8], transparency: ArrayList[u8], source_gamma: i64, + color_transform: PngColorTransform, profile_assumed: i32) PngDecodeResult { let pixels: ArrayList[u8] = make[u8](width * height * 4); let gamma_table: ArrayList[u8] = make[u8](256); - if (source_gamma != 0) { + let linear_table: ArrayList[i64] = make[i64](256); + if (color_transform.active != 0) { + let linear_sample: i32 = 0; + while (linear_sample < 256) { + push[i64](&linear_table, png_source_sample_to_linear_q30( + linear_sample as u8, source_gamma + )); + linear_sample = linear_sample + 1; + } + } else if (source_gamma != 0) { let gamma_sample: i32 = 0; while (gamma_sample < 256) { push[u8](&gamma_table, @@ -528,7 +824,8 @@ fn png_expand_rgba(raw: []u8, width: i32, height: i32, row_bytes: i32, } else if (color_type == 3) { let index: i32 = png_sample(row, x, bit_depth); if (index * 3 + 2 >= palette.len) { - free[u8](&gamma_table); free[u8](&pixels); + free[i64](&linear_table); free[u8](&gamma_table); + free[u8](&pixels); return png_decode_fail(PngDecodeError.palette_index, (y * width + x) as i64); } @@ -547,7 +844,23 @@ fn png_expand_rgba(raw: []u8, width: i32, height: i32, row_bytes: i32, blue = png_sample_u8(png_sample(row, x * 4 + 2, bit_depth), bit_depth); alpha = png_sample_u8(png_sample(row, x * 4 + 3, bit_depth), bit_depth); } - if (gamma_table.len == 256) { + if (linear_table.len == 256) { + let linear_red: i64 = linear_table.data[red as i32]; + let linear_green: i64 = linear_table.data[green as i32]; + let linear_blue: i64 = linear_table.data[blue as i32]; + red = png_linear_q30_to_srgb(png_transform_linear_channel( + color_transform.m00, color_transform.m01, color_transform.m02, + linear_red, linear_green, linear_blue + )); + green = png_linear_q30_to_srgb(png_transform_linear_channel( + color_transform.m10, color_transform.m11, color_transform.m12, + linear_red, linear_green, linear_blue + )); + blue = png_linear_q30_to_srgb(png_transform_linear_channel( + color_transform.m20, color_transform.m21, color_transform.m22, + linear_red, linear_green, linear_blue + )); + } else if (gamma_table.len == 256) { red = gamma_table.data[red as i32]; green = gamma_table.data[green as i32]; blue = gamma_table.data[blue as i32]; @@ -558,6 +871,7 @@ fn png_expand_rgba(raw: []u8, width: i32, height: i32, row_bytes: i32, } y = y + 1; } + free[i64](&linear_table); free[u8](&gamma_table); return PngDecodeResult{ .pixels = pixels, .width = width, .height = height, @@ -594,6 +908,7 @@ fn png_decode(bytes: []u8) PngDecodeResult { let saw_gamma: i32 = 0; let saw_chrm: i32 = 0; let source_gamma: i64 = 0; + let color_transform: PngColorTransform = png_color_transform_identity(); let offset: i32 = 8; while (offset < bytes.len && saw_end == 0) { @@ -751,7 +1066,8 @@ fn png_decode(bytes: []u8) PngDecodeResult { return png_decode_parse_fail(&idat, &palette, &transparency, PngDecodeError.invalid_chunk_order, offset as i64); } - if (png_chrm_is_srgb(bytes, data_offset, length) == 0) { + color_transform = png_chrm_transform(bytes, data_offset, length); + if (color_transform.valid == 0) { return png_decode_parse_fail(&idat, &palette, &transparency, PngDecodeError.unsupported_color_profile, offset as i64); } @@ -828,11 +1144,15 @@ fn png_decode(bytes: []u8) PngDecodeResult { return png_decode_fail(error, error_offset); } let conversion_gamma: i64 = source_gamma; - if (saw_srgb != 0) { conversion_gamma = 0; } + let effective_transform: PngColorTransform = color_transform; + if (saw_srgb != 0) { + conversion_gamma = 0; + effective_transform = png_color_transform_identity(); + } let result: PngDecodeResult = png_expand_rgba( scanlines.bytes.data[0..scanlines.bytes.len], width, height, row_bytes64 as i32, bit_depth, color_type, palette, transparency, - conversion_gamma, + conversion_gamma, effective_transform, (saw_srgb == 0 && !(source_gamma != 0 && saw_chrm != 0)) as i32 ); free[u8](&scanlines.bytes); free[u8](&palette); free[u8](&transparency); diff --git a/tests/png_decode_contract.zag b/tests/png_decode_contract.zag index 6b32e23..f1468e8 100644 --- a/tests/png_decode_contract.zag +++ b/tests/png_decode_contract.zag @@ -226,14 +226,26 @@ fn fixture_profile_png(gamma_count: i32, chromaticity_kind: i32, free[u8](&gamma); if (chromaticity_kind != 0) { let chromaticity: ArrayList[u8] = make[u8](32); - png_write_u32_be(&chromaticity, 31270); - png_write_u32_be(&chromaticity, 32900); - png_write_u32_be(&chromaticity, 64000); - png_write_u32_be(&chromaticity, 33000); - png_write_u32_be(&chromaticity, 30000); - png_write_u32_be(&chromaticity, 60000); - png_write_u32_be(&chromaticity, 15000); - png_write_u32_be(&chromaticity, 6000 + (chromaticity_kind - 1)); + let white_x: i64 = 31270; let white_y: i64 = 32900; + let red_x: i64 = 64000; let red_y: i64 = 33000; + let green_x: i64 = 30000; let green_y: i64 = 60000; + let blue_x: i64 = 15000; let blue_y: i64 = 6000; + if (chromaticity_kind == 2) { + red_x = 68000; red_y = 32000; + green_x = 26500; green_y = 69000; + } else if (chromaticity_kind == 3) { + white_x = 34567; white_y = 35850; + } else if (chromaticity_kind == 4) { + green_x = red_x; green_y = red_y; + } + png_write_u32_be(&chromaticity, white_x); + png_write_u32_be(&chromaticity, white_y); + png_write_u32_be(&chromaticity, red_x); + png_write_u32_be(&chromaticity, red_y); + png_write_u32_be(&chromaticity, green_x); + png_write_u32_be(&chromaticity, green_y); + png_write_u32_be(&chromaticity, blue_x); + png_write_u32_be(&chromaticity, blue_y); png_write_chunk(&output, "cHRM", chromaticity.data[0..chromaticity.len]); free[u8](&chromaticity); } @@ -454,14 +466,44 @@ fn color_profile_contract(state: *TestState) void { "meaningless zero gamma is ignored while fallback truth stays visible"); png_decode_free(&zero); png_encode_free(&zero_fixture); - let precedence_fixture: PngEncodeResult = fixture_profile_png(1, 0, 100000, 1); + let p3_fixture: PngEncodeResult = fixture_profile_png(1, 2, 100000, 0); + let p3: PngDecodeResult = png_decode( + p3_fixture.bytes.data[0..p3_fixture.bytes.len] + ); + expect(state, p3.error == PngDecodeError.none && + p3.profile_assumed == 0 && p3.pixels.data[0] == 64 && + p3.pixels.data[1] == 103 && p3.pixels.data[2] == 126, + "Display P3 primaries convert through the bounded linear matrix path"); + png_decode_free(&p3); png_encode_free(&p3_fixture); + + let adapted_fixture: PngEncodeResult = fixture_profile_png(1, 3, 100000, 0); + let adapted: PngDecodeResult = png_decode( + adapted_fixture.bytes.data[0..adapted_fixture.bytes.len] + ); + expect(state, adapted.error == PngDecodeError.none && + adapted.profile_assumed == 0 && adapted.pixels.data[0] == 69 && + adapted.pixels.data[1] == 102 && adapted.pixels.data[2] == 123, + "non-D65 white points use deterministic Bradford adaptation"); + png_decode_free(&adapted); png_encode_free(&adapted_fixture); + + let assumed_fixture: PngEncodeResult = fixture_profile_png(0, 2, 0, 0); + let assumed: PngDecodeResult = png_decode( + assumed_fixture.bytes.data[0..assumed_fixture.bytes.len] + ); + expect(state, assumed.error == PngDecodeError.none && + assumed.profile_assumed != 0 && assumed.pixels.data[0] == 11 && + assumed.pixels.data[1] == 35 && assumed.pixels.data[2] == 53, + "chromaticities without a transfer curve preserve fallback-profile truth"); + png_decode_free(&assumed); png_encode_free(&assumed_fixture); + + let precedence_fixture: PngEncodeResult = fixture_profile_png(1, 2, 100000, 1); let precedence: PngDecodeResult = png_decode( precedence_fixture.bytes.data[0..precedence_fixture.bytes.len] ); expect(state, precedence.error == PngDecodeError.none && precedence.profile_assumed == 0 && precedence.pixels.data[0] == 17 && precedence.pixels.data[1] == 34 && precedence.pixels.data[2] == 51, - "explicit sRGB takes precedence over accompanying gamma metadata"); + "explicit sRGB takes precedence over accompanying gamma and chromaticities"); png_decode_free(&precedence); png_encode_free(&precedence_fixture); let duplicate_fixture: PngEncodeResult = fixture_profile_png(2, 0, 45455, 0); @@ -472,13 +514,13 @@ fn color_profile_contract(state: *TestState) void { "duplicate color metadata fails before image decompression"); png_decode_free(&duplicate); png_encode_free(&duplicate_fixture); - let foreign_fixture: PngEncodeResult = fixture_profile_png(1, 2, 45455, 0); - let foreign: PngDecodeResult = png_decode( - foreign_fixture.bytes.data[0..foreign_fixture.bytes.len] + let singular_fixture: PngEncodeResult = fixture_profile_png(1, 4, 45455, 0); + let singular: PngDecodeResult = png_decode( + singular_fixture.bytes.data[0..singular_fixture.bytes.len] ); - expect(state, foreign.error == PngDecodeError.unsupported_color_profile, - "unconverted chromaticities fail rather than being mislabeled sRGB"); - png_decode_free(&foreign); png_encode_free(&foreign_fixture); + expect(state, singular.error == PngDecodeError.unsupported_color_profile, + "singular chromaticities fail before decompression or conversion"); + png_decode_free(&singular); png_encode_free(&singular_fixture); let ramp: ArrayList[u8] = make[u8](256); let sample: i32 = 0; From 98d35d638e350f76c42258915c5d085ee3b411b0 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 14:06:06 -0700 Subject: [PATCH 036/103] Execute bounded PNG ICC matrix profiles --- README.md | 9 +- docs/architecture/images.md | 31 +++- docs/architecture/render-resources.md | 7 +- src/render/png_decode.zag | 234 +++++++++++++++++++++++- tests/png_decode_contract.zag | 250 ++++++++++++++++++++++++-- tests/png_decode_fuzz.zag | 27 +++ 6 files changed, 532 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index b88d874..aaa5a6c 100644 --- a/README.md +++ b/README.md @@ -184,9 +184,12 @@ pixels. End-to-end decoded-resource CPU goldens pass at 1x, 1.25x, 1.5x, 2x, and 3x on both compiled architectures. Non-sRGB gamma transfer curves and arbitrary valid `cHRM` primaries now convert deterministically to sRGB, including Bradford white-point adaptation; eight full transfer ramps and Display P3/D50 -fixtures match independent references. A deterministic 40,000-input mutation -harness plus 4,096 repeated cleanup cycles passes on both architectures. ICC -profile conversion and coverage-guided sanitizer evidence remain open, so the `G3-PNG` +fixtures match independent references. Bounded `iCCP` ingestion executes ICC +v2/v4 RGB matrix profiles in XYZ PCS with shared identity, gamma, or type-0 +parametric curves; malformed wrappers, profile bounds, unsupported sampled +curves, and LUT profiles fail explicitly. A deterministic 60,000-input PNG/ICC +validation harness plus 4,096 repeated cleanup cycles passes on both architectures. General +ICC curve/LUT execution and coverage-guided sanitizer evidence remain open, so the `G3-PNG` completion box remains deliberately unchecked. See the [image and PNG ingestion contract](docs/architecture/images.md). diff --git a/docs/architecture/images.md b/docs/architecture/images.md index ff538aa..15ddcaf 100644 --- a/docs/architecture/images.md +++ b/docs/architecture/images.md @@ -62,6 +62,19 @@ becoming declared profile truth. Eight complete 256-value ramps plus Display P3 and non-D65 fixtures match independently calculated references exactly on both compiled architectures. +An `iCCP` wrapper must provide a valid 1-to-79-byte PNG profile name, compression +method zero, and one independently bounded zlib stream. The decompressed ICC +profile is limited to 4 MiB and must be an ICC v2 or v4 scanner/display RGB +matrix profile using XYZ PCS and the D50 PCS illuminant. Zagkit validates the +declared profile size, header signature, reserved header bytes, rendering intent, +bounded aligned tag table, unique required tags, XYZ tag types, and transfer +curve types before executing it. The current executable subset accepts the six +required RGB matrix/TRC tags when all three curves are the same identity curve, +single gamma curve, or type-0 parametric gamma curve. Matrix columns are converted +from D50 PCS into linear sRGB, then encoded through the canonical sRGB transfer. +An understood `iCCP` profile takes precedence over compatibility `sRGB`, `gAMA`, +and `cHRM` chunks. The result records declared rather than assumed profile truth. + `png_decode_resource_spec` adapts a successful owned result to the normal copying resource-store API, so the decoder can be freed immediately after insertion. @@ -72,17 +85,19 @@ the canonical resource store, and compares full-surface hashes at 1x, 1.25x, headless scale evidence, not native compositor or monitor evidence. The strict deterministic fuzz gate decodes 20,000 arbitrary byte streams, -20,000 structured mutations of a valid PNG, every strict seed prefix, and 4,096 -decode/free repetitions on both x86-64 and ARM64. Every result must preserve -success or failure ownership invariants. Coverage-guided sanitizer campaigns +20,000 structured mutations of a valid PNG, 20,000 arbitrary decompressed ICC +profiles, every strict seed prefix, and 4,096 decode/free repetitions on both +x86-64 and ARM64. Every result must preserve success or failure ownership +invariants. Coverage-guided sanitizer campaigns and a larger published malformed corpus remain additional required evidence. Dimension, encoded-data, decompressed-scanline, output-pixel, palette-index, -filter, color-matrix, and arithmetic limits fail before out-of-bounds access. -Explicit iCCP still fails as an unsupported color profile. Unknown interlace -methods fail before decompression. This unavailable path keeps `G3-PNG` open -until ICC profile conversion and -coverage-guided malformed-input evidence land. +filter, color-matrix, ICC inflate, ICC tag-table, and arithmetic limits fail +before out-of-bounds access. Sampled or per-channel curves, parametric curve +types 1 through 4, monochrome profiles, LUT profiles, device-link profiles, and +unsupported `cICP` metadata fail as unsupported color profiles. Unknown interlace +methods fail before decompression. These paths keep `G3-PNG` open until general +ICC execution and coverage-guided malformed-input evidence land. Linear-light filtering, wide-gamut output surfaces, mipmapping, high-quality downsampling, image tiling, and GPU upload caches also remain diff --git a/docs/architecture/render-resources.md b/docs/architecture/render-resources.md index 9222ba3..3efb52d 100644 --- a/docs/architecture/render-resources.md +++ b/docs/architecture/render-resources.md @@ -54,9 +54,10 @@ canonical resource section before operations and reconstructs it through the same bounded validation path; decoding and re-encoding are byte identical. The current executable contract decodes bounded PNG assets into canonical owned -RGBA8 resources, including arbitrary valid `cHRM` conversion, but does not yet -decode SVG, execute ICC profiles, validate font tables, interpret glyph payload -schemas, rasterize path strokes, target wide-gamut output surfaces, cache +RGBA8 resources, including arbitrary valid `cHRM` conversion and bounded ICC +matrix/TRC profiles. It does not yet decode SVG, execute general ICC curve/LUT +profiles, validate font tables, interpret glyph payload schemas, rasterize path +strokes, target wide-gamut output surfaces, cache platform uploads, or implement memory-pressure eviction. Those capabilities remain unavailable until their own malformed-input, rendering, replacement, and cleanup suites pass. diff --git a/src/render/png_decode.zag b/src/render/png_decode.zag index 77b44a4..1a60def 100644 --- a/src/render/png_decode.zag +++ b/src/render/png_decode.zag @@ -65,6 +65,16 @@ struct PngColorTransform { valid: i32, } +struct PngIccTag { offset: i32, size: i32, found: i32, valid: i32 } + +struct PngIccVectorResult { value: PngVectorF64, valid: i32 } + +struct PngIccProfileResult { + transform: PngColorTransform, + source_gamma: i64, + valid: i32, +} + fn png_decode_input_limit() i64 { return 134217728; } fn png_decode_pixel_byte_limit() i64 { return 67108864; } fn png_decode_dimension_limit() i32 { return 8192; } @@ -368,6 +378,197 @@ fn png_chrm_transform(bytes: []u8, offset: i32, }; } +fn png_data_is(bytes: []u8, offset: i32, name: []u8) i32 { + if (name.len != 4 || offset < 0 || offset + 4 > bytes.len) { return 0; } + let i: i32 = 0; + while (i < 4) { + if (bytes[offset + i] != name[i]) { return 0; } + i = i + 1; + } + return 1; +} + +fn png_read_s15_fixed16(bytes: []u8, offset: i32) f64 { + let raw: i64 = png_read_u32_be(bytes, offset); + if (raw >= 2147483648) { raw = raw - 4294967296; } + return raw as f64 / 65536.0; +} + +fn png_icc_tag(bytes: []u8, signature: []u8) PngIccTag { + let missing: PngIccTag = PngIccTag{ + .offset = 0, .size = 0, .found = 0, .valid = 1, + }; + if (bytes.len < 132) { missing.valid = 0; return missing; } + let count64: i64 = png_read_u32_be(bytes, 128); + if (count64 > 128 || 132 + count64 * 12 > bytes.len as i64) { + missing.valid = 0; return missing; + } + let result: PngIccTag = missing; + let index: i32 = 0; + while (index < count64 as i32) { + let entry: i32 = 132 + index * 12; + let tag_offset64: i64 = png_read_u32_be(bytes, entry + 4); + let tag_size64: i64 = png_read_u32_be(bytes, entry + 8); + if (tag_offset64 < 132 + count64 * 12 || tag_offset64 % 4 != 0 || + tag_size64 < 12 || tag_offset64 + tag_size64 > bytes.len as i64) { + result.valid = 0; return result; + } + if (png_data_is(bytes, entry, signature) != 0) { + if (result.found != 0) { result.valid = 0; return result; } + result.offset = tag_offset64 as i32; + result.size = tag_size64 as i32; + result.found = 1; + } + index = index + 1; + } + return result; +} + +fn png_icc_xyz(bytes: []u8, tag: PngIccTag) PngIccVectorResult { + let result: PngIccVectorResult = PngIccVectorResult{ + .value = PngVectorF64{ .x = 0.0, .y = 0.0, .z = 0.0 }, .valid = 0, + }; + if (tag.valid == 0 || tag.found == 0 || tag.size < 20 || + png_data_is(bytes, tag.offset, "XYZ ") == 0 || + png_read_u32_be(bytes, tag.offset + 4) != 0) { return result; } + result.value = PngVectorF64{ + .x = png_read_s15_fixed16(bytes, tag.offset + 8), + .y = png_read_s15_fixed16(bytes, tag.offset + 12), + .z = png_read_s15_fixed16(bytes, tag.offset + 16), + }; + if (result.value.x != result.value.x || result.value.y != result.value.y || + result.value.z != result.value.z || png_f64_abs(result.value.x) > 4.0 || + png_f64_abs(result.value.y) > 4.0 || + png_f64_abs(result.value.z) > 4.0) { return result; } + result.valid = 1; + return result; +} + +fn png_icc_curve_gamma(bytes: []u8, tag: PngIccTag) i64 { + if (tag.valid == 0 || tag.found == 0 || tag.size < 12 || + png_read_u32_be(bytes, tag.offset + 4) != 0) { return 0; } + let numerator: i64 = 0; + let encoded_gamma: i64 = 0; + if (png_data_is(bytes, tag.offset, "curv") != 0) { + let count: i64 = png_read_u32_be(bytes, tag.offset + 8); + if (count == 0) { return 100000; } + if (count != 1 || tag.size < 14) { return 0; } + encoded_gamma = png_read_u16_be(bytes, tag.offset + 12); + numerator = 25600000; + } else if (png_data_is(bytes, tag.offset, "para") != 0) { + if (tag.size < 16 || png_read_u16_be(bytes, tag.offset + 8) != 0 || + png_read_u16_be(bytes, tag.offset + 10) != 0) { return 0; } + encoded_gamma = png_read_u32_be(bytes, tag.offset + 12); + numerator = 6553600000; + } else { return 0; } + if (encoded_gamma <= 0 || encoded_gamma > 655360) { return 0; } + let source_gamma: i64 = (numerator + encoded_gamma / 2) / encoded_gamma; + if (source_gamma < 10000 || source_gamma > 1000000) { return 0; } + return source_gamma; +} + +fn png_icc_matrix_profile(bytes: []u8) PngIccProfileResult { + let result: PngIccProfileResult = PngIccProfileResult{ + .transform = png_color_transform_identity(), .source_gamma = 0, .valid = 0, + }; + if (bytes.len < 132 || bytes.len > 4194304 || + png_read_u32_be(bytes, 0) != bytes.len as i64 || + (bytes[8] != 2 && bytes[8] != 4) || + !(png_data_is(bytes, 12, "scnr") != 0 || + png_data_is(bytes, 12, "mntr") != 0) || + png_data_is(bytes, 16, "RGB ") == 0 || + png_data_is(bytes, 20, "XYZ ") == 0 || + png_data_is(bytes, 36, "acsp") == 0 || + png_read_u32_be(bytes, 64) > 3) { return result; } + let reserved: i32 = 100; + while (reserved < 128) { + if (bytes[reserved] != 0) { return result; } + reserved = reserved + 1; + } + let illuminant: PngVectorF64 = PngVectorF64{ + .x = png_read_s15_fixed16(bytes, 68), + .y = png_read_s15_fixed16(bytes, 72), + .z = png_read_s15_fixed16(bytes, 76), + }; + if (png_f64_abs(illuminant.x - 0.9642) > 0.001 || + png_f64_abs(illuminant.y - 1.0) > 0.001 || + png_f64_abs(illuminant.z - 0.8249) > 0.001) { return result; } + let red_xyz: PngIccVectorResult = png_icc_xyz(bytes, png_icc_tag(bytes, "rXYZ")); + let green_xyz: PngIccVectorResult = png_icc_xyz(bytes, png_icc_tag(bytes, "gXYZ")); + let blue_xyz: PngIccVectorResult = png_icc_xyz(bytes, png_icc_tag(bytes, "bXYZ")); + if (red_xyz.valid == 0 || green_xyz.valid == 0 || blue_xyz.valid == 0) { + return result; + } + let red_gamma: i64 = png_icc_curve_gamma(bytes, png_icc_tag(bytes, "rTRC")); + let green_gamma: i64 = png_icc_curve_gamma(bytes, png_icc_tag(bytes, "gTRC")); + let blue_gamma: i64 = png_icc_curve_gamma(bytes, png_icc_tag(bytes, "bTRC")); + if (red_gamma == 0 || red_gamma != green_gamma || red_gamma != blue_gamma) { + return result; + } + let source: PngMatrixF64 = PngMatrixF64{ + .m00 = red_xyz.value.x, .m01 = green_xyz.value.x, .m02 = blue_xyz.value.x, + .m10 = red_xyz.value.y, .m11 = green_xyz.value.y, .m12 = blue_xyz.value.y, + .m20 = red_xyz.value.z, .m21 = green_xyz.value.z, .m22 = blue_xyz.value.z, + }; + let target: PngMatrixF64 = PngMatrixF64{ + .m00 = 0.4360747, .m01 = 0.3850649, .m02 = 0.1430804, + .m10 = 0.2225045, .m11 = 0.7168786, .m12 = 0.0606169, + .m20 = 0.0139322, .m21 = 0.0971045, .m22 = 0.7141733, + }; + let target_inverse: PngMatrixF64Result = png_matrix_inverse(target); + if (target_inverse.valid == 0) { return result; } + let matrix: PngMatrixF64 = png_matrix_multiply(target_inverse.matrix, source); + if (png_matrix_finite_bounded(matrix, 8.0) == 0) { return result; } + result.transform = PngColorTransform{ + .m00 = png_quantize_matrix_value(matrix.m00), + .m01 = png_quantize_matrix_value(matrix.m01), + .m02 = png_quantize_matrix_value(matrix.m02), + .m10 = png_quantize_matrix_value(matrix.m10), + .m11 = png_quantize_matrix_value(matrix.m11), + .m12 = png_quantize_matrix_value(matrix.m12), + .m20 = png_quantize_matrix_value(matrix.m20), + .m21 = png_quantize_matrix_value(matrix.m21), + .m22 = png_quantize_matrix_value(matrix.m22), + .active = 1, .valid = 1, + }; + result.source_gamma = red_gamma; result.valid = 1; + return result; +} + +fn png_iccp_profile(bytes: []u8, offset: i32, + length: i32) PngIccProfileResult { + let result: PngIccProfileResult = PngIccProfileResult{ + .transform = png_color_transform_identity(), .source_gamma = 0, .valid = 0, + }; + if (length < 9) { return result; } + let name_end: i32 = offset; + while (name_end < offset + length && name_end - offset < 80 && + bytes[name_end] != 0) { + let value: i32 = bytes[name_end] as i32; + if (!((value >= 32 && value <= 126) || value >= 161) || + ((name_end == offset || bytes[name_end - 1] == 32) && value == 32)) { + return result; + } + name_end = name_end + 1; + } + if (name_end == offset || name_end >= offset + length || + name_end - offset > 79 || bytes[name_end] != 0 || + bytes[name_end - 1] == 32 || name_end + 2 >= offset + length || + bytes[name_end + 1] != 0) { return result; } + let compressed_offset: i32 = name_end + 2; + let inflated: ZlibInflateResult = zlib_inflate( + bytes[compressed_offset..offset + length], 4194304 + ); + if (inflated.error != ZlibInflateError.none) { + zlib_inflate_free(&inflated); return result; + } + let parsed: PngIccProfileResult = png_icc_matrix_profile( + inflated.bytes.data[0..inflated.bytes.len] + ); + zlib_inflate_free(&inflated); + return parsed; +} + fn png_color_channels(color_type: i32) i32 { if (color_type == 0 || color_type == 3) { return 1; } if (color_type == 2) { return 3; } @@ -907,8 +1108,11 @@ fn png_decode(bytes: []u8) PngDecodeResult { let saw_srgb: i32 = 0; let saw_gamma: i32 = 0; let saw_chrm: i32 = 0; + let saw_icc: i32 = 0; let source_gamma: i64 = 0; let color_transform: PngColorTransform = png_color_transform_identity(); + let icc_gamma: i64 = 0; + let icc_transform: PngColorTransform = png_color_transform_identity(); let offset: i32 = 8; while (offset < bytes.len && saw_end == 0) { @@ -1073,6 +1277,29 @@ fn png_decode(bytes: []u8) PngDecodeResult { } saw_chrm = 1; } else if (png_chunk_is(bytes, offset, "iCCP") != 0) { + if (saw_icc != 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.duplicate_chunk, offset as i64); + } + if (saw_palette != 0 || saw_idat != 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.invalid_chunk_order, offset as i64); + } + if (!(color_type == 2 || color_type == 3 || color_type == 6)) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.unsupported_color_profile, offset as i64); + } + let profile: PngIccProfileResult = png_iccp_profile( + bytes, data_offset, length + ); + if (profile.valid == 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.unsupported_color_profile, offset as i64); + } + icc_gamma = profile.source_gamma; + icc_transform = profile.transform; + saw_icc = 1; + } else if (png_chunk_is(bytes, offset, "cICP") != 0) { return png_decode_parse_fail(&idat, &palette, &transparency, PngDecodeError.unsupported_color_profile, offset as i64); } else { @@ -1149,11 +1376,16 @@ fn png_decode(bytes: []u8) PngDecodeResult { conversion_gamma = 0; effective_transform = png_color_transform_identity(); } + if (saw_icc != 0) { + conversion_gamma = icc_gamma; + effective_transform = icc_transform; + } let result: PngDecodeResult = png_expand_rgba( scanlines.bytes.data[0..scanlines.bytes.len], width, height, row_bytes64 as i32, bit_depth, color_type, palette, transparency, conversion_gamma, effective_transform, - (saw_srgb == 0 && !(source_gamma != 0 && saw_chrm != 0)) as i32 + (saw_srgb == 0 && saw_icc == 0 && + !(source_gamma != 0 && saw_chrm != 0)) as i32 ); free[u8](&scanlines.bytes); free[u8](&palette); free[u8](&transparency); return result; diff --git a/tests/png_decode_contract.zag b/tests/png_decode_contract.zag index f1468e8..13bea72 100644 --- a/tests/png_decode_contract.zag +++ b/tests/png_decode_contract.zag @@ -201,8 +201,79 @@ fn fixture_rewrite_crc(bytes: *ArrayList[u8], chunk_offset: i32) void { fixture_write_u32_be(bytes, crc_offset, crc); } +fn fixture_write_signature(bytes: *ArrayList[u8], offset: i32, + signature: []u8) void { + let i: i32 = 0; + while (i < 4) { bytes.*.data[offset + i] = signature[i]; i = i + 1; } +} + +fn fixture_icc_tag(bytes: *ArrayList[u8], entry: i32, signature: []u8, + data_offset: i32, data_size: i32) void { + fixture_write_signature(bytes, entry, signature); + fixture_write_u32_be(bytes, entry + 4, data_offset); + fixture_write_u32_be(bytes, entry + 8, data_size); +} + +fn fixture_icc_xyz(bytes: *ArrayList[u8], offset: i32, + x: i64, y: i64, z: i64) void { + fixture_write_signature(bytes, offset, "XYZ "); + fixture_write_u32_be(bytes, offset + 8, x); + fixture_write_u32_be(bytes, offset + 12, y); + fixture_write_u32_be(bytes, offset + 16, z); +} + +fn fixture_matrix_icc(kind: i32) ArrayList[u8] { + let bytes: ArrayList[u8] = make[u8](280); + let i: i32 = 0; + while (i < 280) { push[u8](&bytes, 0); i = i + 1; } + fixture_write_u32_be(&bytes, 0, 280); + bytes.data[8] = 4; + fixture_write_signature(&bytes, 12, "mntr"); + fixture_write_signature(&bytes, 16, "RGB "); + fixture_write_signature(&bytes, 20, "XYZ "); + fixture_write_signature(&bytes, 36, "acsp"); + fixture_write_u32_be(&bytes, 68, 63190); + fixture_write_u32_be(&bytes, 72, 65536); + fixture_write_u32_be(&bytes, 76, 54061); + fixture_write_u32_be(&bytes, 128, 6); + fixture_icc_tag(&bytes, 132, "rXYZ", 204, 20); + fixture_icc_tag(&bytes, 144, "gXYZ", 224, 20); + fixture_icc_tag(&bytes, 156, "bXYZ", 244, 20); + fixture_icc_tag(&bytes, 168, "rTRC", 264, 12); + fixture_icc_tag(&bytes, 180, "gTRC", 264, 12); + fixture_icc_tag(&bytes, 192, "bTRC", 264, 12); + if (kind == 2) { + fixture_icc_xyz(&bytes, 204, 33759, 15807, 0 - 69); + fixture_icc_xyz(&bytes, 224, 19135, 45367, 2745); + fixture_icc_xyz(&bytes, 244, 10296, 4363, 51385); + } else { + fixture_icc_xyz(&bytes, 204, 28579, 14582, 913); + fixture_icc_xyz(&bytes, 224, 25236, 46981, 6364); + fixture_icc_xyz(&bytes, 244, 9377, 3973, 46804); + } + fixture_write_signature(&bytes, 264, "curv"); + if (kind == 3) { fixture_write_u32_be(&bytes, 136, 4000000); } + if (kind == 4) { + fixture_write_u32_be(&bytes, 176, 16); + fixture_write_u32_be(&bytes, 188, 16); + fixture_write_u32_be(&bytes, 200, 16); + fixture_write_u32_be(&bytes, 272, 2); + bytes.data[278] = 255; bytes.data[279] = 255; + } + if (kind == 8) { + fixture_write_u32_be(&bytes, 176, 16); + fixture_write_u32_be(&bytes, 188, 16); + fixture_write_u32_be(&bytes, 200, 16); + fixture_write_signature(&bytes, 264, "para"); + fixture_write_u32_be(&bytes, 276, 65536); + } + if (kind == 10) { fixture_write_signature(&bytes, 144, "rXYZ"); } + if (kind == 11) { fixture_write_u32_be(&bytes, 0, 279); } + return bytes; +} + fn fixture_profile_png(gamma_count: i32, chromaticity_kind: i32, - gamma_value: i64, include_srgb: i32) PngEncodeResult { + gamma_value: i64, include_srgb: i32, icc_kind: i32) PngEncodeResult { let compressed: ZlibStoreEncodeResult = zlib_store_encode("\x00\x11\x22\x33\xff"); let output: ArrayList[u8] = make[u8](compressed.bytes.len + 160); push[u8](&output, 137); push[u8](&output, 80); @@ -224,6 +295,36 @@ fn fixture_profile_png(gamma_count: i32, chromaticity_kind: i32, gamma_index = gamma_index + 1; } free[u8](&gamma); + if (icc_kind == 9) { + png_write_chunk(&output, "cICP", "\x01\x0d\x00\x01"); + } else if (icc_kind != 0) { + let profile: ArrayList[u8] = fixture_matrix_icc(icc_kind); + let compressed_profile: ZlibStoreEncodeResult = zlib_store_encode( + profile.data[0..profile.len] + ); + if (icc_kind == 12 && compressed_profile.bytes.len > 3) { + compressed_profile.bytes.data[2] = + compressed_profile.bytes.data[2] ^ 1; + } + let iccp: ArrayList[u8] = make[u8](compressed_profile.bytes.len + 16); + let name: []u8 = "Zagkit matrix"; + if (icc_kind == 5) { push[u8](&iccp, 32); } + let name_index: i32 = 0; + while (name_index < name.len) { + push[u8](&iccp, name[name_index]); name_index = name_index + 1; + } + push[u8](&iccp, 0); + if (icc_kind == 6) { push[u8](&iccp, 1); } + else { push[u8](&iccp, 0); } + png_copy_append(&iccp, + compressed_profile.bytes.data[0..compressed_profile.bytes.len]); + png_write_chunk(&output, "iCCP", iccp.data[0..iccp.len]); + if (icc_kind == 7) { + png_write_chunk(&output, "iCCP", iccp.data[0..iccp.len]); + } + free[u8](&iccp); zlib_store_encode_free(&compressed_profile); + free[u8](&profile); + } if (chromaticity_kind != 0) { let chromaticity: ArrayList[u8] = make[u8](32); let white_x: i64 = 31270; let white_y: i64 = 32900; @@ -426,7 +527,7 @@ fn color_contract(state: *TestState) void { } fn color_profile_contract(state: *TestState) void { - let declared_fixture: PngEncodeResult = fixture_profile_png(1, 1, 45455, 0); + let declared_fixture: PngEncodeResult = fixture_profile_png(1, 1, 45455, 0, 0); let declared: PngDecodeResult = png_decode( declared_fixture.bytes.data[0..declared_fixture.bytes.len] ); @@ -436,7 +537,7 @@ fn color_profile_contract(state: *TestState) void { "declared gamma and standard primaries convert without assumed profile truth"); png_decode_free(&declared); png_encode_free(&declared_fixture); - let gamma_only_fixture: PngEncodeResult = fixture_profile_png(1, 0, 45455, 0); + let gamma_only_fixture: PngEncodeResult = fixture_profile_png(1, 0, 45455, 0, 0); let gamma_only: PngDecodeResult = png_decode( gamma_only_fixture.bytes.data[0..gamma_only_fixture.bytes.len] ); @@ -445,7 +546,7 @@ fn color_profile_contract(state: *TestState) void { "gamma without declared primaries keeps the fallback-profile truth visible"); png_decode_free(&gamma_only); png_encode_free(&gamma_only_fixture); - let linear_fixture: PngEncodeResult = fixture_profile_png(1, 0, 100000, 0); + let linear_fixture: PngEncodeResult = fixture_profile_png(1, 0, 100000, 0, 0); let linear: PngDecodeResult = png_decode( linear_fixture.bytes.data[0..linear_fixture.bytes.len] ); @@ -456,7 +557,7 @@ fn color_profile_contract(state: *TestState) void { "linear-gamma samples convert deterministically into canonical sRGB"); png_decode_free(&linear); png_encode_free(&linear_fixture); - let zero_fixture: PngEncodeResult = fixture_profile_png(1, 0, 0, 0); + let zero_fixture: PngEncodeResult = fixture_profile_png(1, 0, 0, 0, 0); let zero: PngDecodeResult = png_decode( zero_fixture.bytes.data[0..zero_fixture.bytes.len] ); @@ -466,7 +567,7 @@ fn color_profile_contract(state: *TestState) void { "meaningless zero gamma is ignored while fallback truth stays visible"); png_decode_free(&zero); png_encode_free(&zero_fixture); - let p3_fixture: PngEncodeResult = fixture_profile_png(1, 2, 100000, 0); + let p3_fixture: PngEncodeResult = fixture_profile_png(1, 2, 100000, 0, 0); let p3: PngDecodeResult = png_decode( p3_fixture.bytes.data[0..p3_fixture.bytes.len] ); @@ -476,7 +577,7 @@ fn color_profile_contract(state: *TestState) void { "Display P3 primaries convert through the bounded linear matrix path"); png_decode_free(&p3); png_encode_free(&p3_fixture); - let adapted_fixture: PngEncodeResult = fixture_profile_png(1, 3, 100000, 0); + let adapted_fixture: PngEncodeResult = fixture_profile_png(1, 3, 100000, 0, 0); let adapted: PngDecodeResult = png_decode( adapted_fixture.bytes.data[0..adapted_fixture.bytes.len] ); @@ -486,7 +587,7 @@ fn color_profile_contract(state: *TestState) void { "non-D65 white points use deterministic Bradford adaptation"); png_decode_free(&adapted); png_encode_free(&adapted_fixture); - let assumed_fixture: PngEncodeResult = fixture_profile_png(0, 2, 0, 0); + let assumed_fixture: PngEncodeResult = fixture_profile_png(0, 2, 0, 0, 0); let assumed: PngDecodeResult = png_decode( assumed_fixture.bytes.data[0..assumed_fixture.bytes.len] ); @@ -496,7 +597,7 @@ fn color_profile_contract(state: *TestState) void { "chromaticities without a transfer curve preserve fallback-profile truth"); png_decode_free(&assumed); png_encode_free(&assumed_fixture); - let precedence_fixture: PngEncodeResult = fixture_profile_png(1, 2, 100000, 1); + let precedence_fixture: PngEncodeResult = fixture_profile_png(1, 2, 100000, 1, 0); let precedence: PngDecodeResult = png_decode( precedence_fixture.bytes.data[0..precedence_fixture.bytes.len] ); @@ -506,7 +607,7 @@ fn color_profile_contract(state: *TestState) void { "explicit sRGB takes precedence over accompanying gamma and chromaticities"); png_decode_free(&precedence); png_encode_free(&precedence_fixture); - let duplicate_fixture: PngEncodeResult = fixture_profile_png(2, 0, 45455, 0); + let duplicate_fixture: PngEncodeResult = fixture_profile_png(2, 0, 45455, 0, 0); let duplicate: PngDecodeResult = png_decode( duplicate_fixture.bytes.data[0..duplicate_fixture.bytes.len] ); @@ -514,7 +615,7 @@ fn color_profile_contract(state: *TestState) void { "duplicate color metadata fails before image decompression"); png_decode_free(&duplicate); png_encode_free(&duplicate_fixture); - let singular_fixture: PngEncodeResult = fixture_profile_png(1, 4, 45455, 0); + let singular_fixture: PngEncodeResult = fixture_profile_png(1, 4, 45455, 0, 0); let singular: PngDecodeResult = png_decode( singular_fixture.bytes.data[0..singular_fixture.bytes.len] ); @@ -522,6 +623,133 @@ fn color_profile_contract(state: *TestState) void { "singular chromaticities fail before decompression or conversion"); png_decode_free(&singular); png_encode_free(&singular_fixture); + let icc_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 1); + let icc: PngDecodeResult = png_decode( + icc_fixture.bytes.data[0..icc_fixture.bytes.len] + ); + expect(state, icc.error == PngDecodeError.none && icc.profile_assumed == 0 && + icc.pixels.data[0] == 73 && icc.pixels.data[1] == 102 && + icc.pixels.data[2] == 124, + "bounded ICC matrix and identity TRCs execute as declared profile truth"); + png_decode_free(&icc); png_encode_free(&icc_fixture); + + let icc_p3_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 2); + let icc_p3: PngDecodeResult = png_decode( + icc_p3_fixture.bytes.data[0..icc_p3_fixture.bytes.len] + ); + expect(state, icc_p3.error == PngDecodeError.none && + icc_p3.profile_assumed == 0 && icc_p3.pixels.data[0] == 64 && + icc_p3.pixels.data[1] == 103 && icc_p3.pixels.data[2] == 126, + "Display P3 ICC matrix columns convert through the D50 PCS"); + png_decode_free(&icc_p3); png_encode_free(&icc_p3_fixture); + + let icc_precedence_fixture: PngEncodeResult = fixture_profile_png( + 1, 1, 45455, 1, 2 + ); + let icc_precedence: PngDecodeResult = png_decode( + icc_precedence_fixture.bytes.data[0..icc_precedence_fixture.bytes.len] + ); + expect(state, icc_precedence.error == PngDecodeError.none && + icc_precedence.pixels.data[0] == 64 && + icc_precedence.pixels.data[1] == 103 && + icc_precedence.pixels.data[2] == 126, + "understood iCCP takes precedence over compatibility color chunks"); + png_decode_free(&icc_precedence); + png_encode_free(&icc_precedence_fixture); + + let bad_icc_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 3); + let bad_icc: PngDecodeResult = png_decode( + bad_icc_fixture.bytes.data[0..bad_icc_fixture.bytes.len] + ); + expect(state, bad_icc.error == PngDecodeError.unsupported_color_profile, + "ICC tag offsets cannot escape the bounded decompressed profile"); + png_decode_free(&bad_icc); png_encode_free(&bad_icc_fixture); + + let lut_icc_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 4); + let lut_icc: PngDecodeResult = png_decode( + lut_icc_fixture.bytes.data[0..lut_icc_fixture.bytes.len] + ); + expect(state, lut_icc.error == PngDecodeError.unsupported_color_profile, + "unevaluated sampled ICC curves fail instead of being approximated"); + png_decode_free(&lut_icc); png_encode_free(&lut_icc_fixture); + + let bad_name_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 5); + let bad_name: PngDecodeResult = png_decode( + bad_name_fixture.bytes.data[0..bad_name_fixture.bytes.len] + ); + expect(state, bad_name.error == PngDecodeError.unsupported_color_profile, + "iCCP profile names reject leading and consecutive spaces"); + png_decode_free(&bad_name); png_encode_free(&bad_name_fixture); + + let bad_method_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 6); + let bad_method: PngDecodeResult = png_decode( + bad_method_fixture.bytes.data[0..bad_method_fixture.bytes.len] + ); + expect(state, bad_method.error == PngDecodeError.unsupported_color_profile, + "unknown iCCP compression methods fail before profile inflate"); + png_decode_free(&bad_method); png_encode_free(&bad_method_fixture); + + let duplicate_icc_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 7); + let duplicate_icc: PngDecodeResult = png_decode( + duplicate_icc_fixture.bytes.data[0..duplicate_icc_fixture.bytes.len] + ); + expect(state, duplicate_icc.error == PngDecodeError.duplicate_chunk, + "duplicate iCCP chunks fail before image decompression"); + png_decode_free(&duplicate_icc); png_encode_free(&duplicate_icc_fixture); + + let parametric_icc_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 8 + ); + let parametric_icc: PngDecodeResult = png_decode( + parametric_icc_fixture.bytes.data[0..parametric_icc_fixture.bytes.len] + ); + expect(state, parametric_icc.error == PngDecodeError.none && + parametric_icc.profile_assumed == 0 && + parametric_icc.pixels.data[0] == 73 && + parametric_icc.pixels.data[1] == 102 && + parametric_icc.pixels.data[2] == 124, + "ICC parametric gamma curves execute through the same linear pipeline"); + png_decode_free(¶metric_icc); + png_encode_free(¶metric_icc_fixture); + + let cicp_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 9); + let cicp: PngDecodeResult = png_decode( + cicp_fixture.bytes.data[0..cicp_fixture.bytes.len] + ); + expect(state, cicp.error == PngDecodeError.unsupported_color_profile, + "unimplemented higher-precedence cICP metadata fails explicitly"); + png_decode_free(&cicp); png_encode_free(&cicp_fixture); + + let duplicate_tag_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 10 + ); + let duplicate_tag: PngDecodeResult = png_decode( + duplicate_tag_fixture.bytes.data[0..duplicate_tag_fixture.bytes.len] + ); + expect(state, duplicate_tag.error == PngDecodeError.unsupported_color_profile, + "duplicate required ICC tags fail without ambiguous profile execution"); + png_decode_free(&duplicate_tag); png_encode_free(&duplicate_tag_fixture); + + let size_mismatch_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 11 + ); + let size_mismatch: PngDecodeResult = png_decode( + size_mismatch_fixture.bytes.data[0..size_mismatch_fixture.bytes.len] + ); + expect(state, size_mismatch.error == PngDecodeError.unsupported_color_profile, + "ICC declared size must equal the bounded decompressed profile"); + png_decode_free(&size_mismatch); png_encode_free(&size_mismatch_fixture); + + let broken_iccp_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 12 + ); + let broken_iccp: PngDecodeResult = png_decode( + broken_iccp_fixture.bytes.data[0..broken_iccp_fixture.bytes.len] + ); + expect(state, broken_iccp.error == PngDecodeError.unsupported_color_profile, + "corrupt iCCP zlib streams fail before ICC tag parsing"); + png_decode_free(&broken_iccp); png_encode_free(&broken_iccp_fixture); + let ramp: ArrayList[u8] = make[u8](256); let sample: i32 = 0; while (sample < 256) { diff --git a/tests/png_decode_fuzz.zag b/tests/png_decode_fuzz.zag index bde75be..8ec7a4e 100644 --- a/tests/png_decode_fuzz.zag +++ b/tests/png_decode_fuzz.zag @@ -104,6 +104,32 @@ fn mutation_contract(state: *FuzzState) void { png_encode_free(&encoded); cpu_surface_free(&surface); } +fn icc_profile_contract(state: *FuzzState) void { + let random: i64 = 297121507; + let valid: i32 = 1; + let case_index: i32 = 0; + while (case_index < 20000 && valid != 0) { + let length: i32 = (fuzz_next(&random) % 1025) as i32; + let bytes: ArrayList[u8] = make[u8](length); + let i: i32 = 0; + while (i < length) { + push[u8](&bytes, (fuzz_next(&random) & 255) as u8); + i = i + 1; + } + let profile: PngIccProfileResult = png_icc_matrix_profile( + bytes.data[0..bytes.len] + ); + if ((profile.valid != 0 && profile.valid != 1) || + (profile.valid != 0 && (profile.transform.valid == 0 || + profile.transform.active == 0 || profile.source_gamma <= 0))) { + valid = 0; + } + free[u8](&bytes); case_index = case_index + 1; + } + fuzz_expect(state, valid != 0 && case_index == 20000, + "20000 arbitrary ICC profiles remain bounded and fail closed"); +} + fn prefix_and_repeat_contract(state: *FuzzState) void { let surface: CpuSurface = fuzz_seed_surface(); let encoded: PngEncodeResult = png_encode_surface(surface); @@ -143,6 +169,7 @@ fn main() i32 { let state: FuzzState = FuzzState{ .passed = 0, .failed = 0 }; arbitrary_bytes_contract(&state); mutation_contract(&state); + icc_profile_contract(&state); prefix_and_repeat_contract(&state); _zag_print("PNG decode fuzz: pass="); let passed: []u8 = _zag_i64_to_str(state.passed as i64); From 05640be3e94ad598541de85b47f4dfc3b847e4bb Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 14:07:00 -0700 Subject: [PATCH 037/103] Pin Zag generic pointer slice support --- contracts/toolchain.json | 7 ++++--- contracts/upstream-zag.json | 10 +++++++++- docs/milestones/0000-product-contract.md | 9 +++++---- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/contracts/toolchain.json b/contracts/toolchain.json index 9101421..32f292b 100644 --- a/contracts/toolchain.json +++ b/contracts/toolchain.json @@ -4,17 +4,18 @@ "zag": { "repository": "https://github.com/Sylorlabs/zag", "resolved_ref": "refs/heads/agent/zagkit-prerequisites", - "commit": "63e3979d91bfd06f53ee317bd6bf428673814bdf", + "commit": "43870455a07bf8e7d4adf38fad807fe3baee4e26", "compiler_version": "2026.07.0-dev", "edition": "2026" }, "audited_on": "2026-08-06", "pin_policy": "Every Zagkit release replaces this record with the exact Zag source commit used to build and test it. Branch names and local dirty state are never release identities.", "evidence": [ - "Zag commit 63e3979d91bfd06f53ee317bd6bf428673814bdf was exported from the exact clean Git tree and audited on 2026-08-06.", + "Zag commit 43870455a07bf8e7d4adf38fad807fe3baee4e26 was exported from the exact clean Git tree and audited on 2026-08-06.", "zag-poc/zag.mod at the pinned commit declares version 2026.07.0-dev and edition 2026.", "The clean pinned tree self-hosted to a byte-identical stage-2/stage-3 fixpoint with no external tools and rebuilt zagd.", - "The clean pinned tree passed resource embedding and aggregate switch value-flow witnesses on x86-64 and qemu-user ARM64 plus foreground-cache, cache-integration, and tooling gates.", + "The clean pinned tree self-hosts the generic numeric raw-pointer slice repair to a byte-identical compiler fixpoint; its aliasing, mutation, nested-slice, explicit-bound, and negative contracts pass on x86-64 and qemu-user ARM64.", + "The clean pinned tree passes compiler-owned resource embedding conformance on x86-64 and qemu-user ARM64, including parser, formatter, cache, and deterministic identity cases.", "The pinned tree provides pure-Zag CRC32, Adler32, deterministic stored-zlib output, and general stored/fixed/dynamic DEFLATE inflate; the same 14-check suite passes on x86-64 and qemu-user ARM64." ] } diff --git a/contracts/upstream-zag.json b/contracts/upstream-zag.json index 59fc163..3faf29a 100644 --- a/contracts/upstream-zag.json +++ b/contracts/upstream-zag.json @@ -1,7 +1,7 @@ { "schema_version": 1, "audited_repository": "https://github.com/Sylorlabs/zag", - "audited_commit": "63e3979d91bfd06f53ee317bd6bf428673814bdf", + "audited_commit": "43870455a07bf8e7d4adf38fad807fe3baee4e26", "audited_on": "2026-08-06", "state_vocabulary": ["available", "partial", "missing"], "evidence_rule": "Available requires native executable conformance at the audited commit. Source presence, a cross compile, or a dirty working tree does not qualify.", @@ -126,6 +126,14 @@ "evidence": "The pinned zagd architecture documents a checksummed incremental declaration index and background semantic rechecking, but not an in-process incremental parser or stable state-preserving library reload contract.", "exit_gate": "Dependency invalidation, state preserving reload, ABI rejection, rollback, file race, crash recovery, and deterministic event hooks pass end to end." }, + { + "id": "generic-numeric-pointer-slices", + "category": "language-runtime", + "state": "available", + "needed_by": [2, 3, 4, 5, 6, 7], + "evidence": "The exact clean pinned commit self-hosts bounded *E[lo..hi] formation for every supported numeric slice family. zag-poc/tests/run_generic_pointer_slice.sh passes typed []i64 formation, parameter flow, backing-store mutation aliasing, nested slices, x86-64 execution, qemu-user ARM64 execution, and rejection of open-ended raw-pointer slices.", + "exit_gate": "Keep typed bounded raw-pointer slice formation, element stride, aliasing, nested slicing, negative diagnostics, and cross-target execution green at every pinned compiler revision." + }, { "id": "checksum-and-zlib-inflate", "category": "standard-library", diff --git a/docs/milestones/0000-product-contract.md b/docs/milestones/0000-product-contract.md index 23de311..4c56a99 100644 --- a/docs/milestones/0000-product-contract.md +++ b/docs/milestones/0000-product-contract.md @@ -25,11 +25,12 @@ ## Zag audit boundary The release pin resolves the clean Zag `agent/zagkit-prerequisites` commit -`63e3979d91bfd06f53ee317bd6bf428673814bdf`. It was exported from the exact Git +`43870455a07bf8e7d4adf38fad807fe3baee4e26`. It was exported from the exact Git tree, self-hosted to a byte-identical stage-2/stage-3 fixpoint without external -tools, and passed the resource, aggregate switch value-flow, foreground-cache, -cache-integration, tooling, checksum, stored-zlib, and general DEFLATE gates on -x86-64 and qemu-user ARM64. The neighboring +tools, and passed resource embedding plus the 14-check general DEFLATE suite on +x86-64 and qemu-user ARM64. It also passed generic numeric raw-pointer slice +formation, aliasing, nested-slice, explicit-bound, and negative contracts on both +compiled architectures. The neighboring local Zag checkout still contains extensive unrelated changes; those changes were not used to claim capabilities or construct this release identity. From 293ab4031603667e819131b4b0b53f3c379ea621 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 14:15:01 -0700 Subject: [PATCH 038/103] Execute ICC parametric transfer curves --- README.md | 11 ++- docs/architecture/images.md | 13 ++- src/render/png_decode.zag | 171 +++++++++++++++++++++++++++------- tests/png_decode_contract.zag | 79 +++++++++++++++- tests/png_decode_fuzz.zag | 3 +- 5 files changed, 229 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index aaa5a6c..eae99d8 100644 --- a/README.md +++ b/README.md @@ -185,11 +185,12 @@ and 3x on both compiled architectures. Non-sRGB gamma transfer curves and arbitrary valid `cHRM` primaries now convert deterministically to sRGB, including Bradford white-point adaptation; eight full transfer ramps and Display P3/D50 fixtures match independent references. Bounded `iCCP` ingestion executes ICC -v2/v4 RGB matrix profiles in XYZ PCS with shared identity, gamma, or type-0 -parametric curves; malformed wrappers, profile bounds, unsupported sampled -curves, and LUT profiles fail explicitly. A deterministic 60,000-input PNG/ICC -validation harness plus 4,096 repeated cleanup cycles passes on both architectures. General -ICC curve/LUT execution and coverage-guided sanitizer evidence remain open, so the `G3-PNG` +v2/v4 RGB matrix profiles in XYZ PCS with shared identity, gamma, or ICC +parametric curve types 0 through 4. Malformed wrappers and profile bounds fail; +sampled curves, differing per-channel curves, and LUT profiles remain explicit +unsupported paths. A deterministic 60,000-input PNG/ICC validation harness plus +4,096 repeated cleanup cycles passes on both architectures. Sampled/per-channel +ICC, LUT execution, and coverage-guided sanitizer evidence remain open, so the `G3-PNG` completion box remains deliberately unchecked. See the [image and PNG ingestion contract](docs/architecture/images.md). diff --git a/docs/architecture/images.md b/docs/architecture/images.md index 15ddcaf..4546e13 100644 --- a/docs/architecture/images.md +++ b/docs/architecture/images.md @@ -70,7 +70,10 @@ declared profile size, header signature, reserved header bytes, rendering intent bounded aligned tag table, unique required tags, XYZ tag types, and transfer curve types before executing it. The current executable subset accepts the six required RGB matrix/TRC tags when all three curves are the same identity curve, -single gamma curve, or type-0 parametric gamma curve. Matrix columns are converted +single gamma curve, or parametric curve type 0 through 4. Each piecewise function +executes from bounded fixed-point coefficients and clips to its declared range; +undefined or unsafe coefficient domains fail before pixels are touched. Matrix +columns are converted from D50 PCS into linear sRGB, then encoded through the canonical sRGB transfer. An understood `iCCP` profile takes precedence over compatibility `sRGB`, `gAMA`, and `cHRM` chunks. The result records declared rather than assumed profile truth. @@ -93,11 +96,11 @@ and a larger published malformed corpus remain additional required evidence. Dimension, encoded-data, decompressed-scanline, output-pixel, palette-index, filter, color-matrix, ICC inflate, ICC tag-table, and arithmetic limits fail -before out-of-bounds access. Sampled or per-channel curves, parametric curve -types 1 through 4, monochrome profiles, LUT profiles, device-link profiles, and +before out-of-bounds access. Sampled or differing per-channel curves, monochrome +profiles, LUT profiles, device-link profiles, and unsupported `cICP` metadata fail as unsupported color profiles. Unknown interlace -methods fail before decompression. These paths keep `G3-PNG` open until general -ICC execution and coverage-guided malformed-input evidence land. +methods fail before decompression. These paths keep `G3-PNG` open until sampled, +per-channel, and LUT ICC execution plus coverage-guided malformed-input evidence land. Linear-light filtering, wide-gamut output surfaces, mipmapping, high-quality downsampling, image tiling, and GPU upload caches also remain diff --git a/src/render/png_decode.zag b/src/render/png_decode.zag index 1a60def..f960b04 100644 --- a/src/render/png_decode.zag +++ b/src/render/png_decode.zag @@ -69,9 +69,16 @@ struct PngIccTag { offset: i32, size: i32, found: i32, valid: i32 } struct PngIccVectorResult { value: PngVectorF64, valid: i32 } +struct PngTransferCurve { + kind: i32, + g: i64, + a: i64, b: i64, c: i64, d: i64, e: i64, f: i64, + valid: i32, +} + struct PngIccProfileResult { transform: PngColorTransform, - source_gamma: i64, + curve: PngTransferCurve, valid: i32, } @@ -388,9 +395,14 @@ fn png_data_is(bytes: []u8, offset: i32, name: []u8) i32 { return 1; } -fn png_read_s15_fixed16(bytes: []u8, offset: i32) f64 { +fn png_read_i32_be(bytes: []u8, offset: i32) i64 { let raw: i64 = png_read_u32_be(bytes, offset); if (raw >= 2147483648) { raw = raw - 4294967296; } + return raw; +} + +fn png_read_s15_fixed16(bytes: []u8, offset: i32) f64 { + let raw: i64 = png_read_i32_be(bytes, offset); return raw as f64 / 65536.0; } @@ -444,32 +456,82 @@ fn png_icc_xyz(bytes: []u8, tag: PngIccTag) PngIccVectorResult { return result; } -fn png_icc_curve_gamma(bytes: []u8, tag: PngIccTag) i64 { +fn png_transfer_curve_invalid() PngTransferCurve { + return PngTransferCurve{ + .kind = 0, .g = 0, .a = 0, .b = 0, .c = 0, .d = 0, + .e = 0, .f = 0, .valid = 0, + }; +} + +fn png_transfer_curve_external() PngTransferCurve { + let curve: PngTransferCurve = png_transfer_curve_invalid(); + curve.valid = 1; return curve; +} + +fn png_transfer_curve_equal(left: PngTransferCurve, + right: PngTransferCurve) i32 { + return (left.valid == right.valid && left.kind == right.kind && + left.g == right.g && left.a == right.a && left.b == right.b && + left.c == right.c && left.d == right.d && left.e == right.e && + left.f == right.f) as i32; +} + +fn png_icc_curve(bytes: []u8, tag: PngIccTag) PngTransferCurve { + let curve: PngTransferCurve = png_transfer_curve_invalid(); if (tag.valid == 0 || tag.found == 0 || tag.size < 12 || - png_read_u32_be(bytes, tag.offset + 4) != 0) { return 0; } - let numerator: i64 = 0; - let encoded_gamma: i64 = 0; + png_read_u32_be(bytes, tag.offset + 4) != 0) { return curve; } if (png_data_is(bytes, tag.offset, "curv") != 0) { let count: i64 = png_read_u32_be(bytes, tag.offset + 8); - if (count == 0) { return 100000; } - if (count != 1 || tag.size < 14) { return 0; } - encoded_gamma = png_read_u16_be(bytes, tag.offset + 12); - numerator = 25600000; + curve.kind = 1; + if (count == 0) { curve.g = 1048576; curve.valid = 1; return curve; } + if (count != 1 || tag.size < 14) { return curve; } + curve.g = (png_read_u16_be(bytes, tag.offset + 12) as i64) * 4096; } else if (png_data_is(bytes, tag.offset, "para") != 0) { - if (tag.size < 16 || png_read_u16_be(bytes, tag.offset + 8) != 0 || - png_read_u16_be(bytes, tag.offset + 10) != 0) { return 0; } - encoded_gamma = png_read_u32_be(bytes, tag.offset + 12); - numerator = 6553600000; - } else { return 0; } - if (encoded_gamma <= 0 || encoded_gamma > 655360) { return 0; } - let source_gamma: i64 = (numerator + encoded_gamma / 2) / encoded_gamma; - if (source_gamma < 10000 || source_gamma > 1000000) { return 0; } - return source_gamma; + let function_type: i32 = png_read_u16_be(bytes, tag.offset + 8); + let parameter_count: i32 = 0; + if (function_type == 0) { parameter_count = 1; } + else if (function_type == 1) { parameter_count = 3; } + else if (function_type == 2) { parameter_count = 4; } + else if (function_type == 3) { parameter_count = 5; } + else if (function_type == 4) { parameter_count = 7; } + else { return curve; } + if (png_read_u16_be(bytes, tag.offset + 10) != 0 || + tag.size < 12 + parameter_count * 4) { return curve; } + curve.kind = function_type + 1; + curve.g = png_read_i32_be(bytes, tag.offset + 12) * 16; + if (parameter_count > 1) { + curve.a = png_read_i32_be(bytes, tag.offset + 16) * 16384; + curve.b = png_read_i32_be(bytes, tag.offset + 20) * 16384; + } + if (parameter_count > 3) { + curve.c = png_read_i32_be(bytes, tag.offset + 24) * 16384; + } + if (function_type == 3) { + curve.d = png_read_i32_be(bytes, tag.offset + 28) * 16384; + } else if (function_type == 4) { + curve.d = png_read_i32_be(bytes, tag.offset + 28) * 16384; + curve.e = png_read_i32_be(bytes, tag.offset + 32) * 16384; + curve.f = png_read_i32_be(bytes, tag.offset + 36) * 16384; + } + } else { return curve; } + if (curve.g < 104858 || curve.g > 10485760 || + (curve.kind > 1 && (curve.a <= 0 || curve.a > 2147483648 || + curve.a + curve.b > 1073741824)) || + (curve.kind >= 4 && (curve.d < 0 || curve.d > 1073741824)) || + (curve.kind >= 4 && + (curve.a * curve.d) / 1073741824 + curve.b < 0) || + png_f64_abs(curve.b as f64) > 2147483648.0 || + png_f64_abs(curve.c as f64) > 2147483648.0 || + png_f64_abs(curve.e as f64) > 2147483648.0 || + png_f64_abs(curve.f as f64) > 2147483648.0) { return curve; } + curve.valid = 1; + return curve; } fn png_icc_matrix_profile(bytes: []u8) PngIccProfileResult { let result: PngIccProfileResult = PngIccProfileResult{ - .transform = png_color_transform_identity(), .source_gamma = 0, .valid = 0, + .transform = png_color_transform_identity(), + .curve = png_transfer_curve_invalid(), .valid = 0, }; if (bytes.len < 132 || bytes.len > 4194304 || png_read_u32_be(bytes, 0) != bytes.len as i64 || @@ -499,10 +561,18 @@ fn png_icc_matrix_profile(bytes: []u8) PngIccProfileResult { if (red_xyz.valid == 0 || green_xyz.valid == 0 || blue_xyz.valid == 0) { return result; } - let red_gamma: i64 = png_icc_curve_gamma(bytes, png_icc_tag(bytes, "rTRC")); - let green_gamma: i64 = png_icc_curve_gamma(bytes, png_icc_tag(bytes, "gTRC")); - let blue_gamma: i64 = png_icc_curve_gamma(bytes, png_icc_tag(bytes, "bTRC")); - if (red_gamma == 0 || red_gamma != green_gamma || red_gamma != blue_gamma) { + let red_curve: PngTransferCurve = png_icc_curve( + bytes, png_icc_tag(bytes, "rTRC") + ); + let green_curve: PngTransferCurve = png_icc_curve( + bytes, png_icc_tag(bytes, "gTRC") + ); + let blue_curve: PngTransferCurve = png_icc_curve( + bytes, png_icc_tag(bytes, "bTRC") + ); + if (red_curve.valid == 0 || + png_transfer_curve_equal(red_curve, green_curve) == 0 || + png_transfer_curve_equal(red_curve, blue_curve) == 0) { return result; } let source: PngMatrixF64 = PngMatrixF64{ @@ -531,14 +601,15 @@ fn png_icc_matrix_profile(bytes: []u8) PngIccProfileResult { .m22 = png_quantize_matrix_value(matrix.m22), .active = 1, .valid = 1, }; - result.source_gamma = red_gamma; result.valid = 1; + result.curve = red_curve; result.valid = 1; return result; } fn png_iccp_profile(bytes: []u8, offset: i32, length: i32) PngIccProfileResult { let result: PngIccProfileResult = PngIccProfileResult{ - .transform = png_color_transform_identity(), .source_gamma = 0, .valid = 0, + .transform = png_color_transform_identity(), + .curve = png_transfer_curve_invalid(), .valid = 0, }; if (length < 9) { return result; } let name_end: i32 = offset; @@ -948,6 +1019,38 @@ fn png_source_sample_to_linear_q30(sample: u8, gamma: i64) i64 { return png_pow_q30(encoded, decode_exponent); } +fn png_q30_multiply(left: i64, right: i64) i64 { + return (left * right) / png_q30_one(); +} + +fn png_transfer_sample_to_linear_q30(sample: u8, gamma: i64, + curve: PngTransferCurve) i64 { + if (curve.kind == 0) { + return png_source_sample_to_linear_q30(sample, gamma); + } + let x: i64 = ((sample as i64) * png_q30_one() + 127) / 255; + let affine: i64 = png_q30_multiply(curve.a, x) + curve.b; + let value: i64 = 0; + if (curve.kind == 1) { + value = png_pow_q30(x, curve.g); + } else if (curve.kind == 2) { + if (affine >= 0) { value = png_pow_q30(affine, curve.g); } + } else if (curve.kind == 3) { + if (affine >= 0) { value = png_pow_q30(affine, curve.g) + curve.c; } + else { value = curve.c; } + } else if (curve.kind == 4) { + if (x >= curve.d) { value = png_pow_q30(affine, curve.g); } + else { value = png_q30_multiply(curve.c, x); } + } else if (curve.kind == 5) { + if (x >= curve.d) { + value = png_pow_q30(affine, curve.g) + curve.e; + } else { value = png_q30_multiply(curve.c, x) + curve.f; } + } + if (value < 0) { return 0; } + if (value > png_q30_one()) { return png_q30_one(); } + return value; +} + fn png_gamma_sample_to_srgb(sample: u8, gamma: i64) u8 { if (sample == 0 || gamma <= 0) { return 0; } if (sample == 255) { return 255; } @@ -964,7 +1067,7 @@ fn png_transform_linear_channel(a: i64, b: i64, c: i64, fn png_expand_rgba(raw: []u8, width: i32, height: i32, row_bytes: i32, bit_depth: i32, color_type: i32, palette: ArrayList[u8], transparency: ArrayList[u8], source_gamma: i64, - color_transform: PngColorTransform, + color_transform: PngColorTransform, transfer_curve: PngTransferCurve, profile_assumed: i32) PngDecodeResult { let pixels: ArrayList[u8] = make[u8](width * height * 4); let gamma_table: ArrayList[u8] = make[u8](256); @@ -972,8 +1075,8 @@ fn png_expand_rgba(raw: []u8, width: i32, height: i32, row_bytes: i32, if (color_transform.active != 0) { let linear_sample: i32 = 0; while (linear_sample < 256) { - push[i64](&linear_table, png_source_sample_to_linear_q30( - linear_sample as u8, source_gamma + push[i64](&linear_table, png_transfer_sample_to_linear_q30( + linear_sample as u8, source_gamma, transfer_curve )); linear_sample = linear_sample + 1; } @@ -1111,7 +1214,7 @@ fn png_decode(bytes: []u8) PngDecodeResult { let saw_icc: i32 = 0; let source_gamma: i64 = 0; let color_transform: PngColorTransform = png_color_transform_identity(); - let icc_gamma: i64 = 0; + let icc_curve: PngTransferCurve = png_transfer_curve_invalid(); let icc_transform: PngColorTransform = png_color_transform_identity(); let offset: i32 = 8; @@ -1296,7 +1399,7 @@ fn png_decode(bytes: []u8) PngDecodeResult { return png_decode_parse_fail(&idat, &palette, &transparency, PngDecodeError.unsupported_color_profile, offset as i64); } - icc_gamma = profile.source_gamma; + icc_curve = profile.curve; icc_transform = profile.transform; saw_icc = 1; } else if (png_chunk_is(bytes, offset, "cICP") != 0) { @@ -1372,18 +1475,20 @@ fn png_decode(bytes: []u8) PngDecodeResult { } let conversion_gamma: i64 = source_gamma; let effective_transform: PngColorTransform = color_transform; + let effective_curve: PngTransferCurve = png_transfer_curve_external(); if (saw_srgb != 0) { conversion_gamma = 0; effective_transform = png_color_transform_identity(); } if (saw_icc != 0) { - conversion_gamma = icc_gamma; + conversion_gamma = 0; effective_transform = icc_transform; + effective_curve = icc_curve; } let result: PngDecodeResult = png_expand_rgba( scanlines.bytes.data[0..scanlines.bytes.len], width, height, row_bytes64 as i32, bit_depth, color_type, palette, transparency, - conversion_gamma, effective_transform, + conversion_gamma, effective_transform, effective_curve, (saw_srgb == 0 && saw_icc == 0 && !(source_gamma != 0 && saw_chrm != 0)) as i32 ); diff --git a/tests/png_decode_contract.zag b/tests/png_decode_contract.zag index 13bea72..76aa73d 100644 --- a/tests/png_decode_contract.zag +++ b/tests/png_decode_contract.zag @@ -223,10 +223,10 @@ fn fixture_icc_xyz(bytes: *ArrayList[u8], offset: i32, } fn fixture_matrix_icc(kind: i32) ArrayList[u8] { - let bytes: ArrayList[u8] = make[u8](280); + let bytes: ArrayList[u8] = make[u8](320); let i: i32 = 0; - while (i < 280) { push[u8](&bytes, 0); i = i + 1; } - fixture_write_u32_be(&bytes, 0, 280); + while (i < 320) { push[u8](&bytes, 0); i = i + 1; } + fixture_write_u32_be(&bytes, 0, 320); bytes.data[8] = 4; fixture_write_signature(&bytes, 12, "mntr"); fixture_write_signature(&bytes, 16, "RGB "); @@ -268,7 +268,40 @@ fn fixture_matrix_icc(kind: i32) ArrayList[u8] { fixture_write_u32_be(&bytes, 276, 65536); } if (kind == 10) { fixture_write_signature(&bytes, 144, "rXYZ"); } - if (kind == 11) { fixture_write_u32_be(&bytes, 0, 279); } + if (kind == 11) { fixture_write_u32_be(&bytes, 0, 319); } + if (kind == 13) { + fixture_write_u32_be(&bytes, 176, 32); + fixture_write_u32_be(&bytes, 188, 32); + fixture_write_u32_be(&bytes, 200, 32); + fixture_write_signature(&bytes, 264, "para"); + bytes.data[273] = 3; + fixture_write_u32_be(&bytes, 276, 157286); + fixture_write_u32_be(&bytes, 280, 62119); + fixture_write_u32_be(&bytes, 284, 3417); + fixture_write_u32_be(&bytes, 288, 5072); + fixture_write_u32_be(&bytes, 292, 2651); + } + if (kind == 14 || kind == 15 || kind == 16) { + let curve_size: i32 = 24; + let function_type: i32 = 1; + if (kind == 15) { curve_size = 28; function_type = 2; } + if (kind == 16) { curve_size = 40; function_type = 4; } + fixture_write_u32_be(&bytes, 176, curve_size); + fixture_write_u32_be(&bytes, 188, curve_size); + fixture_write_u32_be(&bytes, 200, curve_size); + fixture_write_signature(&bytes, 264, "para"); + bytes.data[273] = function_type as u8; + fixture_write_u32_be(&bytes, 276, 65536); + fixture_write_u32_be(&bytes, 280, 65536); + fixture_write_u32_be(&bytes, 284, 0); + if (kind == 15) { fixture_write_u32_be(&bytes, 288, 0); } + if (kind == 16) { + fixture_write_u32_be(&bytes, 288, 65536); + fixture_write_u32_be(&bytes, 292, 32768); + fixture_write_u32_be(&bytes, 296, 0); + fixture_write_u32_be(&bytes, 300, 0); + } + } return bytes; } @@ -712,6 +745,44 @@ fn color_profile_contract(state: *TestState) void { png_decode_free(¶metric_icc); png_encode_free(¶metric_icc_fixture); + let srgb_curve_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 13 + ); + let srgb_curve: PngDecodeResult = png_decode( + srgb_curve_fixture.bytes.data[0..srgb_curve_fixture.bytes.len] + ); + expect(state, srgb_curve.error == PngDecodeError.none && + srgb_curve.profile_assumed == 0 && srgb_curve.pixels.data[0] == 17 && + srgb_curve.pixels.data[1] == 34 && srgb_curve.pixels.data[2] == 51, + "ICC type-3 sRGB parametric curves execute both piecewise branches"); + png_decode_free(&srgb_curve); png_encode_free(&srgb_curve_fixture); + + let type1_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 14); + let type1_curve: PngDecodeResult = png_decode( + type1_fixture.bytes.data[0..type1_fixture.bytes.len] + ); + let type2_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 15); + let type2_curve: PngDecodeResult = png_decode( + type2_fixture.bytes.data[0..type2_fixture.bytes.len] + ); + let type4_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 16); + let type4_curve: PngDecodeResult = png_decode( + type4_fixture.bytes.data[0..type4_fixture.bytes.len] + ); + expect(state, type1_curve.error == PngDecodeError.none && + type2_curve.error == PngDecodeError.none && + type4_curve.error == PngDecodeError.none && + type1_curve.pixels.data[0] == 73 && type1_curve.pixels.data[1] == 102 && + type1_curve.pixels.data[2] == 124 && + type2_curve.pixels.data[0] == 73 && type2_curve.pixels.data[1] == 102 && + type2_curve.pixels.data[2] == 124 && + type4_curve.pixels.data[0] == 73 && type4_curve.pixels.data[1] == 102 && + type4_curve.pixels.data[2] == 124, + "ICC parametric curve types 1 2 and 4 execute their declared equations"); + png_decode_free(&type1_curve); png_encode_free(&type1_fixture); + png_decode_free(&type2_curve); png_encode_free(&type2_fixture); + png_decode_free(&type4_curve); png_encode_free(&type4_fixture); + let cicp_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 9); let cicp: PngDecodeResult = png_decode( cicp_fixture.bytes.data[0..cicp_fixture.bytes.len] diff --git a/tests/png_decode_fuzz.zag b/tests/png_decode_fuzz.zag index 8ec7a4e..a960c39 100644 --- a/tests/png_decode_fuzz.zag +++ b/tests/png_decode_fuzz.zag @@ -121,7 +121,8 @@ fn icc_profile_contract(state: *FuzzState) void { ); if ((profile.valid != 0 && profile.valid != 1) || (profile.valid != 0 && (profile.transform.valid == 0 || - profile.transform.active == 0 || profile.source_gamma <= 0))) { + profile.transform.active == 0 || profile.curve.valid == 0 || + profile.curve.kind < 1 || profile.curve.kind > 5))) { valid = 0; } free[u8](&bytes); case_index = case_index + 1; From 160a25dfc95e8f3af3d843d02b3188547390cfaa Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 14:19:31 -0700 Subject: [PATCH 039/103] Execute per-channel ICC transfer curves --- README.md | 6 +-- docs/architecture/images.md | 12 +++--- src/render/png_decode.zag | 81 ++++++++++++++++++++++------------- tests/png_decode_contract.zag | 25 +++++++++++ tests/png_decode_fuzz.zag | 8 +++- 5 files changed, 91 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index eae99d8..817ddfe 100644 --- a/README.md +++ b/README.md @@ -187,10 +187,10 @@ Bradford white-point adaptation; eight full transfer ramps and Display P3/D50 fixtures match independent references. Bounded `iCCP` ingestion executes ICC v2/v4 RGB matrix profiles in XYZ PCS with shared identity, gamma, or ICC parametric curve types 0 through 4. Malformed wrappers and profile bounds fail; -sampled curves, differing per-channel curves, and LUT profiles remain explicit +sampled curves and LUT profiles remain explicit unsupported paths. A deterministic 60,000-input PNG/ICC validation harness plus -4,096 repeated cleanup cycles passes on both architectures. Sampled/per-channel -ICC, LUT execution, and coverage-guided sanitizer evidence remain open, so the `G3-PNG` +4,096 repeated cleanup cycles passes on both architectures. Sampled ICC, LUT +execution, and coverage-guided sanitizer evidence remain open, so the `G3-PNG` completion box remains deliberately unchecked. See the [image and PNG ingestion contract](docs/architecture/images.md). diff --git a/docs/architecture/images.md b/docs/architecture/images.md index 4546e13..00aa0f2 100644 --- a/docs/architecture/images.md +++ b/docs/architecture/images.md @@ -69,8 +69,8 @@ matrix profile using XYZ PCS and the D50 PCS illuminant. Zagkit validates the declared profile size, header signature, reserved header bytes, rendering intent, bounded aligned tag table, unique required tags, XYZ tag types, and transfer curve types before executing it. The current executable subset accepts the six -required RGB matrix/TRC tags when all three curves are the same identity curve, -single gamma curve, or parametric curve type 0 through 4. Each piecewise function +required RGB matrix/TRC tags with independent per-channel identity curves, +single gamma curves, or parametric curve types 0 through 4. Each piecewise function executes from bounded fixed-point coefficients and clips to its declared range; undefined or unsafe coefficient domains fail before pixels are touched. Matrix columns are converted @@ -96,11 +96,11 @@ and a larger published malformed corpus remain additional required evidence. Dimension, encoded-data, decompressed-scanline, output-pixel, palette-index, filter, color-matrix, ICC inflate, ICC tag-table, and arithmetic limits fail -before out-of-bounds access. Sampled or differing per-channel curves, monochrome -profiles, LUT profiles, device-link profiles, and +before out-of-bounds access. Sampled curves, monochrome profiles, LUT profiles, +device-link profiles, and unsupported `cICP` metadata fail as unsupported color profiles. Unknown interlace -methods fail before decompression. These paths keep `G3-PNG` open until sampled, -per-channel, and LUT ICC execution plus coverage-guided malformed-input evidence land. +methods fail before decompression. These paths keep `G3-PNG` open until sampled +and LUT ICC execution plus coverage-guided malformed-input evidence land. Linear-light filtering, wide-gamut output surfaces, mipmapping, high-quality downsampling, image tiling, and GPU upload caches also remain diff --git a/src/render/png_decode.zag b/src/render/png_decode.zag index f960b04..291cccb 100644 --- a/src/render/png_decode.zag +++ b/src/render/png_decode.zag @@ -78,7 +78,9 @@ struct PngTransferCurve { struct PngIccProfileResult { transform: PngColorTransform, - curve: PngTransferCurve, + red_curve: PngTransferCurve, + green_curve: PngTransferCurve, + blue_curve: PngTransferCurve, valid: i32, } @@ -468,14 +470,6 @@ fn png_transfer_curve_external() PngTransferCurve { curve.valid = 1; return curve; } -fn png_transfer_curve_equal(left: PngTransferCurve, - right: PngTransferCurve) i32 { - return (left.valid == right.valid && left.kind == right.kind && - left.g == right.g && left.a == right.a && left.b == right.b && - left.c == right.c && left.d == right.d && left.e == right.e && - left.f == right.f) as i32; -} - fn png_icc_curve(bytes: []u8, tag: PngIccTag) PngTransferCurve { let curve: PngTransferCurve = png_transfer_curve_invalid(); if (tag.valid == 0 || tag.found == 0 || tag.size < 12 || @@ -531,7 +525,9 @@ fn png_icc_curve(bytes: []u8, tag: PngIccTag) PngTransferCurve { fn png_icc_matrix_profile(bytes: []u8) PngIccProfileResult { let result: PngIccProfileResult = PngIccProfileResult{ .transform = png_color_transform_identity(), - .curve = png_transfer_curve_invalid(), .valid = 0, + .red_curve = png_transfer_curve_invalid(), + .green_curve = png_transfer_curve_invalid(), + .blue_curve = png_transfer_curve_invalid(), .valid = 0, }; if (bytes.len < 132 || bytes.len > 4194304 || png_read_u32_be(bytes, 0) != bytes.len as i64 || @@ -570,9 +566,8 @@ fn png_icc_matrix_profile(bytes: []u8) PngIccProfileResult { let blue_curve: PngTransferCurve = png_icc_curve( bytes, png_icc_tag(bytes, "bTRC") ); - if (red_curve.valid == 0 || - png_transfer_curve_equal(red_curve, green_curve) == 0 || - png_transfer_curve_equal(red_curve, blue_curve) == 0) { + if (red_curve.valid == 0 || green_curve.valid == 0 || + blue_curve.valid == 0) { return result; } let source: PngMatrixF64 = PngMatrixF64{ @@ -601,7 +596,10 @@ fn png_icc_matrix_profile(bytes: []u8) PngIccProfileResult { .m22 = png_quantize_matrix_value(matrix.m22), .active = 1, .valid = 1, }; - result.curve = red_curve; result.valid = 1; + result.red_curve = red_curve; + result.green_curve = green_curve; + result.blue_curve = blue_curve; + result.valid = 1; return result; } @@ -609,7 +607,9 @@ fn png_iccp_profile(bytes: []u8, offset: i32, length: i32) PngIccProfileResult { let result: PngIccProfileResult = PngIccProfileResult{ .transform = png_color_transform_identity(), - .curve = png_transfer_curve_invalid(), .valid = 0, + .red_curve = png_transfer_curve_invalid(), + .green_curve = png_transfer_curve_invalid(), + .blue_curve = png_transfer_curve_invalid(), .valid = 0, }; if (length < 9) { return result; } let name_end: i32 = offset; @@ -1067,16 +1067,26 @@ fn png_transform_linear_channel(a: i64, b: i64, c: i64, fn png_expand_rgba(raw: []u8, width: i32, height: i32, row_bytes: i32, bit_depth: i32, color_type: i32, palette: ArrayList[u8], transparency: ArrayList[u8], source_gamma: i64, - color_transform: PngColorTransform, transfer_curve: PngTransferCurve, + color_transform: PngColorTransform, + red_curve: PngTransferCurve, green_curve: PngTransferCurve, + blue_curve: PngTransferCurve, profile_assumed: i32) PngDecodeResult { let pixels: ArrayList[u8] = make[u8](width * height * 4); let gamma_table: ArrayList[u8] = make[u8](256); - let linear_table: ArrayList[i64] = make[i64](256); + let red_linear: ArrayList[i64] = make[i64](256); + let green_linear: ArrayList[i64] = make[i64](256); + let blue_linear: ArrayList[i64] = make[i64](256); if (color_transform.active != 0) { let linear_sample: i32 = 0; while (linear_sample < 256) { - push[i64](&linear_table, png_transfer_sample_to_linear_q30( - linear_sample as u8, source_gamma, transfer_curve + push[i64](&red_linear, png_transfer_sample_to_linear_q30( + linear_sample as u8, source_gamma, red_curve + )); + push[i64](&green_linear, png_transfer_sample_to_linear_q30( + linear_sample as u8, source_gamma, green_curve + )); + push[i64](&blue_linear, png_transfer_sample_to_linear_q30( + linear_sample as u8, source_gamma, blue_curve )); linear_sample = linear_sample + 1; } @@ -1128,7 +1138,8 @@ fn png_expand_rgba(raw: []u8, width: i32, height: i32, row_bytes: i32, } else if (color_type == 3) { let index: i32 = png_sample(row, x, bit_depth); if (index * 3 + 2 >= palette.len) { - free[i64](&linear_table); free[u8](&gamma_table); + free[i64](&red_linear); free[i64](&green_linear); + free[i64](&blue_linear); free[u8](&gamma_table); free[u8](&pixels); return png_decode_fail(PngDecodeError.palette_index, (y * width + x) as i64); @@ -1148,10 +1159,10 @@ fn png_expand_rgba(raw: []u8, width: i32, height: i32, row_bytes: i32, blue = png_sample_u8(png_sample(row, x * 4 + 2, bit_depth), bit_depth); alpha = png_sample_u8(png_sample(row, x * 4 + 3, bit_depth), bit_depth); } - if (linear_table.len == 256) { - let linear_red: i64 = linear_table.data[red as i32]; - let linear_green: i64 = linear_table.data[green as i32]; - let linear_blue: i64 = linear_table.data[blue as i32]; + if (red_linear.len == 256) { + let linear_red: i64 = red_linear.data[red as i32]; + let linear_green: i64 = green_linear.data[green as i32]; + let linear_blue: i64 = blue_linear.data[blue as i32]; red = png_linear_q30_to_srgb(png_transform_linear_channel( color_transform.m00, color_transform.m01, color_transform.m02, linear_red, linear_green, linear_blue @@ -1175,7 +1186,8 @@ fn png_expand_rgba(raw: []u8, width: i32, height: i32, row_bytes: i32, } y = y + 1; } - free[i64](&linear_table); + free[i64](&red_linear); free[i64](&green_linear); + free[i64](&blue_linear); free[u8](&gamma_table); return PngDecodeResult{ .pixels = pixels, .width = width, .height = height, @@ -1214,7 +1226,9 @@ fn png_decode(bytes: []u8) PngDecodeResult { let saw_icc: i32 = 0; let source_gamma: i64 = 0; let color_transform: PngColorTransform = png_color_transform_identity(); - let icc_curve: PngTransferCurve = png_transfer_curve_invalid(); + let icc_red_curve: PngTransferCurve = png_transfer_curve_invalid(); + let icc_green_curve: PngTransferCurve = png_transfer_curve_invalid(); + let icc_blue_curve: PngTransferCurve = png_transfer_curve_invalid(); let icc_transform: PngColorTransform = png_color_transform_identity(); let offset: i32 = 8; @@ -1399,7 +1413,9 @@ fn png_decode(bytes: []u8) PngDecodeResult { return png_decode_parse_fail(&idat, &palette, &transparency, PngDecodeError.unsupported_color_profile, offset as i64); } - icc_curve = profile.curve; + icc_red_curve = profile.red_curve; + icc_green_curve = profile.green_curve; + icc_blue_curve = profile.blue_curve; icc_transform = profile.transform; saw_icc = 1; } else if (png_chunk_is(bytes, offset, "cICP") != 0) { @@ -1475,7 +1491,9 @@ fn png_decode(bytes: []u8) PngDecodeResult { } let conversion_gamma: i64 = source_gamma; let effective_transform: PngColorTransform = color_transform; - let effective_curve: PngTransferCurve = png_transfer_curve_external(); + let effective_red_curve: PngTransferCurve = png_transfer_curve_external(); + let effective_green_curve: PngTransferCurve = png_transfer_curve_external(); + let effective_blue_curve: PngTransferCurve = png_transfer_curve_external(); if (saw_srgb != 0) { conversion_gamma = 0; effective_transform = png_color_transform_identity(); @@ -1483,12 +1501,15 @@ fn png_decode(bytes: []u8) PngDecodeResult { if (saw_icc != 0) { conversion_gamma = 0; effective_transform = icc_transform; - effective_curve = icc_curve; + effective_red_curve = icc_red_curve; + effective_green_curve = icc_green_curve; + effective_blue_curve = icc_blue_curve; } let result: PngDecodeResult = png_expand_rgba( scanlines.bytes.data[0..scanlines.bytes.len], width, height, row_bytes64 as i32, bit_depth, color_type, palette, transparency, - conversion_gamma, effective_transform, effective_curve, + conversion_gamma, effective_transform, effective_red_curve, + effective_green_curve, effective_blue_curve, (saw_srgb == 0 && saw_icc == 0 && !(source_gamma != 0 && saw_chrm != 0)) as i32 ); diff --git a/tests/png_decode_contract.zag b/tests/png_decode_contract.zag index 76aa73d..bd8361d 100644 --- a/tests/png_decode_contract.zag +++ b/tests/png_decode_contract.zag @@ -302,6 +302,17 @@ fn fixture_matrix_icc(kind: i32) ArrayList[u8] { fixture_write_u32_be(&bytes, 300, 0); } } + if (kind == 17) { + fixture_icc_tag(&bytes, 168, "rTRC", 264, 16); + fixture_icc_tag(&bytes, 180, "gTRC", 280, 16); + fixture_icc_tag(&bytes, 192, "bTRC", 296, 16); + fixture_write_signature(&bytes, 264, "para"); + fixture_write_u32_be(&bytes, 276, 65536); + fixture_write_signature(&bytes, 280, "para"); + fixture_write_u32_be(&bytes, 292, 131072); + fixture_write_signature(&bytes, 296, "para"); + fixture_write_u32_be(&bytes, 308, 32768); + } return bytes; } @@ -783,6 +794,20 @@ fn color_profile_contract(state: *TestState) void { png_decode_free(&type2_curve); png_encode_free(&type2_fixture); png_decode_free(&type4_curve); png_encode_free(&type4_fixture); + let channel_curve_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 17 + ); + let channel_curve: PngDecodeResult = png_decode( + channel_curve_fixture.bytes.data[0..channel_curve_fixture.bytes.len] + ); + expect(state, channel_curve.error == PngDecodeError.none && + channel_curve.profile_assumed == 0 && + channel_curve.pixels.data[0] == 73 && + channel_curve.pixels.data[1] == 36 && + channel_curve.pixels.data[2] == 178, + "differing ICC channel TRCs execute independently before matrix conversion"); + png_decode_free(&channel_curve); png_encode_free(&channel_curve_fixture); + let cicp_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 9); let cicp: PngDecodeResult = png_decode( cicp_fixture.bytes.data[0..cicp_fixture.bytes.len] diff --git a/tests/png_decode_fuzz.zag b/tests/png_decode_fuzz.zag index a960c39..9b1f243 100644 --- a/tests/png_decode_fuzz.zag +++ b/tests/png_decode_fuzz.zag @@ -121,8 +121,12 @@ fn icc_profile_contract(state: *FuzzState) void { ); if ((profile.valid != 0 && profile.valid != 1) || (profile.valid != 0 && (profile.transform.valid == 0 || - profile.transform.active == 0 || profile.curve.valid == 0 || - profile.curve.kind < 1 || profile.curve.kind > 5))) { + profile.transform.active == 0 || + profile.red_curve.valid == 0 || profile.green_curve.valid == 0 || + profile.blue_curve.valid == 0 || + profile.red_curve.kind < 1 || profile.red_curve.kind > 5 || + profile.green_curve.kind < 1 || profile.green_curve.kind > 5 || + profile.blue_curve.kind < 1 || profile.blue_curve.kind > 5))) { valid = 0; } free[u8](&bytes); case_index = case_index + 1; From 97b6514b3041eb6d808284baa961ce4173a7fd04 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 14:26:36 -0700 Subject: [PATCH 040/103] Execute sampled ICC transfer curves --- README.md | 11 ++-- docs/architecture/images.md | 16 +++-- src/render/png_decode.zag | 116 +++++++++++++++++++++++++++------- tests/png_decode_contract.zag | 75 +++++++++++++++++++++- tests/png_decode_fuzz.zag | 7 +- 5 files changed, 184 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 817ddfe..99ff1d0 100644 --- a/README.md +++ b/README.md @@ -186,11 +186,12 @@ arbitrary valid `cHRM` primaries now convert deterministically to sRGB, includin Bradford white-point adaptation; eight full transfer ramps and Display P3/D50 fixtures match independent references. Bounded `iCCP` ingestion executes ICC v2/v4 RGB matrix profiles in XYZ PCS with shared identity, gamma, or ICC -parametric curve types 0 through 4. Malformed wrappers and profile bounds fail; -sampled curves and LUT profiles remain explicit -unsupported paths. A deterministic 60,000-input PNG/ICC validation harness plus -4,096 repeated cleanup cycles passes on both architectures. Sampled ICC, LUT -execution, and coverage-guided sanitizer evidence remain open, so the `G3-PNG` +parametric curve types 0 through 4 or sampled curves of up to 4,096 entries per +channel. Sampled tables are deterministically resampled to every RGBA8 input code; +malformed wrappers, profile bounds, and LUT profiles fail explicitly. A +deterministic 60,000-input PNG/ICC validation harness plus 4,096 general and +4,096 sampled-profile cleanup cycles passes on both architectures. ICC LUT +execution and coverage-guided sanitizer evidence remain open, so the `G3-PNG` completion box remains deliberately unchecked. See the [image and PNG ingestion contract](docs/architecture/images.md). diff --git a/docs/architecture/images.md b/docs/architecture/images.md index 00aa0f2..7d0f59d 100644 --- a/docs/architecture/images.md +++ b/docs/architecture/images.md @@ -70,8 +70,10 @@ declared profile size, header signature, reserved header bytes, rendering intent bounded aligned tag table, unique required tags, XYZ tag types, and transfer curve types before executing it. The current executable subset accepts the six required RGB matrix/TRC tags with independent per-channel identity curves, -single gamma curves, or parametric curve types 0 through 4. Each piecewise function -executes from bounded fixed-point coefficients and clips to its declared range; +single gamma curves, parametric curve types 0 through 4, or sampled curves with +at most 4,096 entries. Sampled curves use ICC linear interpolation to build an +owned 256-entry fixed-point table for each RGBA8 input channel. Each piecewise +function executes from bounded fixed-point coefficients and clips to its declared range; undefined or unsafe coefficient domains fail before pixels are touched. Matrix columns are converted from D50 PCS into linear sRGB, then encoded through the canonical sRGB transfer. @@ -89,18 +91,18 @@ headless scale evidence, not native compositor or monitor evidence. The strict deterministic fuzz gate decodes 20,000 arbitrary byte streams, 20,000 structured mutations of a valid PNG, 20,000 arbitrary decompressed ICC -profiles, every strict seed prefix, and 4,096 decode/free repetitions on both +profiles, every strict seed prefix, 4,096 general decode/free repetitions, and +4,096 sampled-profile parse/decode/free repetitions on both x86-64 and ARM64. Every result must preserve success or failure ownership invariants. Coverage-guided sanitizer campaigns and a larger published malformed corpus remain additional required evidence. Dimension, encoded-data, decompressed-scanline, output-pixel, palette-index, filter, color-matrix, ICC inflate, ICC tag-table, and arithmetic limits fail -before out-of-bounds access. Sampled curves, monochrome profiles, LUT profiles, -device-link profiles, and +before out-of-bounds access. Monochrome profiles, LUT profiles, device-link profiles, and unsupported `cICP` metadata fail as unsupported color profiles. Unknown interlace -methods fail before decompression. These paths keep `G3-PNG` open until sampled -and LUT ICC execution plus coverage-guided malformed-input evidence land. +methods fail before decompression. These paths keep `G3-PNG` open until ICC LUT +execution plus coverage-guided malformed-input evidence land. Linear-light filtering, wide-gamut output surfaces, mipmapping, high-quality downsampling, image tiling, and GPU upload caches also remain diff --git a/src/render/png_decode.zag b/src/render/png_decode.zag index 291cccb..06256a6 100644 --- a/src/render/png_decode.zag +++ b/src/render/png_decode.zag @@ -73,6 +73,7 @@ struct PngTransferCurve { kind: i32, g: i64, a: i64, b: i64, c: i64, d: i64, e: i64, f: i64, + samples: ArrayList[i64], valid: i32, } @@ -461,10 +462,24 @@ fn png_icc_xyz(bytes: []u8, tag: PngIccTag) PngIccVectorResult { fn png_transfer_curve_invalid() PngTransferCurve { return PngTransferCurve{ .kind = 0, .g = 0, .a = 0, .b = 0, .c = 0, .d = 0, - .e = 0, .f = 0, .valid = 0, + .e = 0, .f = 0, + .samples = ArrayList[i64]{ + .data = null as *i64, .len = 0, .cap = 0, + }, + .valid = 0, }; } +fn png_transfer_curve_free(curve: *PngTransferCurve) void { + if (curve.*.samples.cap > 0) { free[i64](&curve.*.samples); } +} + +fn png_icc_profile_free(profile: *PngIccProfileResult) void { + png_transfer_curve_free(&profile.*.red_curve); + png_transfer_curve_free(&profile.*.green_curve); + png_transfer_curve_free(&profile.*.blue_curve); +} + fn png_transfer_curve_external() PngTransferCurve { let curve: PngTransferCurve = png_transfer_curve_invalid(); curve.valid = 1; return curve; @@ -478,8 +493,37 @@ fn png_icc_curve(bytes: []u8, tag: PngIccTag) PngTransferCurve { let count: i64 = png_read_u32_be(bytes, tag.offset + 8); curve.kind = 1; if (count == 0) { curve.g = 1048576; curve.valid = 1; return curve; } - if (count != 1 || tag.size < 14) { return curve; } - curve.g = (png_read_u16_be(bytes, tag.offset + 12) as i64) * 4096; + if (count == 1) { + if (tag.size < 14) { return curve; } + curve.g = (png_read_u16_be(bytes, tag.offset + 12) as i64) * 4096; + } else { + if (count > 4096 || (tag.size as i64) < 12 + count * 2) { + return curve; + } + curve.kind = 6; + curve.samples = make[i64](256); + let sample: i32 = 0; + while (sample < 256) { + let scaled: i64 = (sample as i64) * (count - 1); + let lower: i32 = (scaled / 255) as i32; + let remainder: i64 = scaled % 255; + let upper: i32 = lower + + (lower + 1 < (count as i32)) as i32; + let lower_value: i64 = png_read_u16_be( + bytes, tag.offset + 12 + lower * 2 + ); + let upper_value: i64 = png_read_u16_be( + bytes, tag.offset + 12 + upper * 2 + ); + let interpolated: i64 = (lower_value * (255 - remainder) + + upper_value * remainder + 127) / 255; + push[i64](&curve.samples, + (interpolated * 1073741824 + 32767) / 65535); + sample = sample + 1; + } + curve.valid = 1; + return curve; + } } else if (png_data_is(bytes, tag.offset, "para") != 0) { let function_type: i32 = png_read_u16_be(bytes, tag.offset + 8); let parameter_count: i32 = 0; @@ -568,6 +612,9 @@ fn png_icc_matrix_profile(bytes: []u8) PngIccProfileResult { ); if (red_curve.valid == 0 || green_curve.valid == 0 || blue_curve.valid == 0) { + png_transfer_curve_free(&red_curve); + png_transfer_curve_free(&green_curve); + png_transfer_curve_free(&blue_curve); return result; } let source: PngMatrixF64 = PngMatrixF64{ @@ -581,9 +628,19 @@ fn png_icc_matrix_profile(bytes: []u8) PngIccProfileResult { .m20 = 0.0139322, .m21 = 0.0971045, .m22 = 0.7141733, }; let target_inverse: PngMatrixF64Result = png_matrix_inverse(target); - if (target_inverse.valid == 0) { return result; } + if (target_inverse.valid == 0) { + png_transfer_curve_free(&red_curve); + png_transfer_curve_free(&green_curve); + png_transfer_curve_free(&blue_curve); + return result; + } let matrix: PngMatrixF64 = png_matrix_multiply(target_inverse.matrix, source); - if (png_matrix_finite_bounded(matrix, 8.0) == 0) { return result; } + if (png_matrix_finite_bounded(matrix, 8.0) == 0) { + png_transfer_curve_free(&red_curve); + png_transfer_curve_free(&green_curve); + png_transfer_curve_free(&blue_curve); + return result; + } result.transform = PngColorTransform{ .m00 = png_quantize_matrix_value(matrix.m00), .m01 = png_quantize_matrix_value(matrix.m01), @@ -1028,6 +1085,9 @@ fn png_transfer_sample_to_linear_q30(sample: u8, gamma: i64, if (curve.kind == 0) { return png_source_sample_to_linear_q30(sample, gamma); } + if (curve.kind == 6 && curve.samples.len == 256) { + return curve.samples.data[sample as i32]; + } let x: i64 = ((sample as i64) * png_q30_one() + 127) / 255; let affine: i64 = png_q30_multiply(curve.a, x) + curve.b; let value: i64 = 0; @@ -1226,10 +1286,9 @@ fn png_decode(bytes: []u8) PngDecodeResult { let saw_icc: i32 = 0; let source_gamma: i64 = 0; let color_transform: PngColorTransform = png_color_transform_identity(); - let icc_red_curve: PngTransferCurve = png_transfer_curve_invalid(); - let icc_green_curve: PngTransferCurve = png_transfer_curve_invalid(); - let icc_blue_curve: PngTransferCurve = png_transfer_curve_invalid(); - let icc_transform: PngColorTransform = png_color_transform_identity(); + let icc_data_offset: i32 = 0; + let icc_data_length: i32 = 0; + let icc_chunk_offset: i32 = 0; let offset: i32 = 8; while (offset < bytes.len && saw_end == 0) { @@ -1406,17 +1465,9 @@ fn png_decode(bytes: []u8) PngDecodeResult { return png_decode_parse_fail(&idat, &palette, &transparency, PngDecodeError.unsupported_color_profile, offset as i64); } - let profile: PngIccProfileResult = png_iccp_profile( - bytes, data_offset, length - ); - if (profile.valid == 0) { - return png_decode_parse_fail(&idat, &palette, &transparency, - PngDecodeError.unsupported_color_profile, offset as i64); - } - icc_red_curve = profile.red_curve; - icc_green_curve = profile.green_curve; - icc_blue_curve = profile.blue_curve; - icc_transform = profile.transform; + icc_data_offset = data_offset; + icc_data_length = length; + icc_chunk_offset = offset; saw_icc = 1; } else if (png_chunk_is(bytes, offset, "cICP") != 0) { return png_decode_parse_fail(&idat, &palette, &transparency, @@ -1466,15 +1517,30 @@ fn png_decode(bytes: []u8) PngDecodeResult { return png_decode_parse_fail(&idat, &palette, &transparency, PngDecodeError.pixel_limit, 8); } + let icc_profile: PngIccProfileResult = PngIccProfileResult{ + .transform = png_color_transform_identity(), + .red_curve = png_transfer_curve_invalid(), + .green_curve = png_transfer_curve_invalid(), + .blue_curve = png_transfer_curve_invalid(), .valid = 0, + }; + if (saw_icc != 0) { + icc_profile = png_iccp_profile(bytes, icc_data_offset, icc_data_length); + if (icc_profile.valid == 0) { + return png_decode_parse_fail(&idat, &palette, &transparency, + PngDecodeError.unsupported_color_profile, icc_chunk_offset as i64); + } + } let inflated: ZlibInflateResult = zlib_inflate( idat.data[0..idat.len], expected64 ); free[u8](&idat); if (inflated.error != ZlibInflateError.none) { + png_icc_profile_free(&icc_profile); zlib_inflate_free(&inflated); free[u8](&palette); free[u8](&transparency); return png_decode_fail(PngDecodeError.compression_error, 8); } if (inflated.bytes.len as i64 != expected64) { + png_icc_profile_free(&icc_profile); zlib_inflate_free(&inflated); free[u8](&palette); free[u8](&transparency); return png_decode_fail(PngDecodeError.decompressed_size, inflated.bytes.len as i64); } @@ -1486,6 +1552,7 @@ fn png_decode(bytes: []u8) PngDecodeResult { if (scanlines.error != PngDecodeError.none) { let error: PngDecodeError = scanlines.error; let error_offset: i64 = scanlines.error_offset; + png_icc_profile_free(&icc_profile); free[u8](&scanlines.bytes); free[u8](&palette); free[u8](&transparency); return png_decode_fail(error, error_offset); } @@ -1500,10 +1567,10 @@ fn png_decode(bytes: []u8) PngDecodeResult { } if (saw_icc != 0) { conversion_gamma = 0; - effective_transform = icc_transform; - effective_red_curve = icc_red_curve; - effective_green_curve = icc_green_curve; - effective_blue_curve = icc_blue_curve; + effective_transform = icc_profile.transform; + effective_red_curve = icc_profile.red_curve; + effective_green_curve = icc_profile.green_curve; + effective_blue_curve = icc_profile.blue_curve; } let result: PngDecodeResult = png_expand_rgba( scanlines.bytes.data[0..scanlines.bytes.len], width, height, @@ -1513,6 +1580,7 @@ fn png_decode(bytes: []u8) PngDecodeResult { (saw_srgb == 0 && saw_icc == 0 && !(source_gamma != 0 && saw_chrm != 0)) as i32 ); + png_icc_profile_free(&icc_profile); free[u8](&scanlines.bytes); free[u8](&palette); free[u8](&transparency); return result; } diff --git a/tests/png_decode_contract.zag b/tests/png_decode_contract.zag index bd8361d..cc5536b 100644 --- a/tests/png_decode_contract.zag +++ b/tests/png_decode_contract.zag @@ -313,6 +313,27 @@ fn fixture_matrix_icc(kind: i32) ArrayList[u8] { fixture_write_signature(&bytes, 296, "para"); fixture_write_u32_be(&bytes, 308, 32768); } + if (kind == 18) { + fixture_write_u32_be(&bytes, 176, 20); + fixture_write_u32_be(&bytes, 188, 20); + fixture_write_u32_be(&bytes, 200, 20); + fixture_write_signature(&bytes, 264, "curv"); + fixture_write_u32_be(&bytes, 272, 4); + bytes.data[278] = 64; + bytes.data[280] = 192; + bytes.data[282] = 255; bytes.data[283] = 255; + } + if (kind == 19) { + fixture_write_signature(&bytes, 264, "curv"); + fixture_write_u32_be(&bytes, 272, 4097); + } + if (kind == 20) { + fixture_write_u32_be(&bytes, 176, 18); + fixture_write_u32_be(&bytes, 188, 18); + fixture_write_u32_be(&bytes, 200, 18); + fixture_write_signature(&bytes, 264, "curv"); + fixture_write_u32_be(&bytes, 272, 4); + } return bytes; } @@ -713,10 +734,60 @@ fn color_profile_contract(state: *TestState) void { let lut_icc: PngDecodeResult = png_decode( lut_icc_fixture.bytes.data[0..lut_icc_fixture.bytes.len] ); - expect(state, lut_icc.error == PngDecodeError.unsupported_color_profile, - "unevaluated sampled ICC curves fail instead of being approximated"); + expect(state, lut_icc.error == PngDecodeError.none && + lut_icc.profile_assumed == 0 && lut_icc.pixels.data[0] == 73 && + lut_icc.pixels.data[1] == 102 && lut_icc.pixels.data[2] == 124, + "two-entry sampled ICC curves interpolate the complete input domain"); png_decode_free(&lut_icc); png_encode_free(&lut_icc_fixture); + let sampled_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 18); + let sampled: PngDecodeResult = png_decode( + sampled_fixture.bytes.data[0..sampled_fixture.bytes.len] + ); + expect(state, sampled.error == PngDecodeError.none && + sampled.profile_assumed == 0 && sampled.pixels.data[0] == 63 && + sampled.pixels.data[1] == 89 && sampled.pixels.data[2] == 108, + "multi-entry sampled ICC curves use deterministic linear interpolation"); + png_decode_free(&sampled); + let sampled_repeat_valid: i32 = 1; + let sampled_repeat: i32 = 0; + while (sampled_repeat < 4096 && sampled_repeat_valid != 0) { + let repeated_sampled: PngDecodeResult = png_decode( + sampled_fixture.bytes.data[0..sampled_fixture.bytes.len] + ); + if (repeated_sampled.error != PngDecodeError.none || + repeated_sampled.pixels.data[0] != 63 || + repeated_sampled.pixels.data[1] != 89 || + repeated_sampled.pixels.data[2] != 108) { + sampled_repeat_valid = 0; + } + png_decode_free(&repeated_sampled); + sampled_repeat = sampled_repeat + 1; + } + expect(state, sampled_repeat_valid != 0 && sampled_repeat == 4096, + "4096 sampled ICC parse decode and cleanup cycles preserve exact output"); + png_encode_free(&sampled_fixture); + + let oversized_curve_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 19 + ); + let oversized_curve: PngDecodeResult = png_decode( + oversized_curve_fixture.bytes.data[0..oversized_curve_fixture.bytes.len] + ); + expect(state, oversized_curve.error == PngDecodeError.unsupported_color_profile, + "sampled ICC curve counts above the execution ceiling fail before allocation"); + png_decode_free(&oversized_curve); png_encode_free(&oversized_curve_fixture); + + let truncated_curve_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 20 + ); + let truncated_curve: PngDecodeResult = png_decode( + truncated_curve_fixture.bytes.data[0..truncated_curve_fixture.bytes.len] + ); + expect(state, truncated_curve.error == PngDecodeError.unsupported_color_profile, + "sampled ICC curve tables cannot cross their declared tag bounds"); + png_decode_free(&truncated_curve); png_encode_free(&truncated_curve_fixture); + let bad_name_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 5); let bad_name: PngDecodeResult = png_decode( bad_name_fixture.bytes.data[0..bad_name_fixture.bytes.len] diff --git a/tests/png_decode_fuzz.zag b/tests/png_decode_fuzz.zag index 9b1f243..ef017df 100644 --- a/tests/png_decode_fuzz.zag +++ b/tests/png_decode_fuzz.zag @@ -124,11 +124,12 @@ fn icc_profile_contract(state: *FuzzState) void { profile.transform.active == 0 || profile.red_curve.valid == 0 || profile.green_curve.valid == 0 || profile.blue_curve.valid == 0 || - profile.red_curve.kind < 1 || profile.red_curve.kind > 5 || - profile.green_curve.kind < 1 || profile.green_curve.kind > 5 || - profile.blue_curve.kind < 1 || profile.blue_curve.kind > 5))) { + profile.red_curve.kind < 1 || profile.red_curve.kind > 6 || + profile.green_curve.kind < 1 || profile.green_curve.kind > 6 || + profile.blue_curve.kind < 1 || profile.blue_curve.kind > 6))) { valid = 0; } + png_icc_profile_free(&profile); free[u8](&bytes); case_index = case_index + 1; } fuzz_expect(state, valid != 0 && case_index == 20000, From 44f15245ae0f7b4af2c7ace835bd44fcac4beb55 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 14:37:15 -0700 Subject: [PATCH 041/103] Execute bounded ICC LUT16 profiles --- README.md | 11 +- docs/architecture/images.md | 27 +++- src/render/png_decode.zag | 255 +++++++++++++++++++++++++++++++++- tests/png_decode_contract.zag | 165 ++++++++++++++++++++++ tests/png_decode_fuzz.zag | 28 +++- 5 files changed, 463 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 99ff1d0..1357831 100644 --- a/README.md +++ b/README.md @@ -187,11 +187,12 @@ Bradford white-point adaptation; eight full transfer ramps and Display P3/D50 fixtures match independent references. Bounded `iCCP` ingestion executes ICC v2/v4 RGB matrix profiles in XYZ PCS with shared identity, gamma, or ICC parametric curve types 0 through 4 or sampled curves of up to 4,096 entries per -channel. Sampled tables are deterministically resampled to every RGBA8 input code; -malformed wrappers, profile bounds, and LUT profiles fail explicitly. A -deterministic 60,000-input PNG/ICC validation harness plus 4,096 general and -4,096 sampled-profile cleanup cycles passes on both architectures. ICC LUT -execution and coverage-guided sanitizer evidence remain open, so the `G3-PNG` +channel. It also executes bounded RGB-to-XYZ `A2B0` `lut16Type` profiles with +owned input tables, a trilinearly interpolated 3D CLUT, and output tables. +Malformed wrappers and profile or table bounds fail explicitly. A deterministic +60,000-input PNG/ICC validation harness plus 4,096 general, sampled-profile, and +LUT16 cleanup cycles passes on both architectures. `lutAToBType`, Lab PCS, and +coverage-guided sanitizer evidence remain open, so the `G3-PNG` completion box remains deliberately unchecked. See the [image and PNG ingestion contract](docs/architecture/images.md). diff --git a/docs/architecture/images.md b/docs/architecture/images.md index 7d0f59d..b3307ab 100644 --- a/docs/architecture/images.md +++ b/docs/architecture/images.md @@ -65,7 +65,7 @@ compiled architectures. An `iCCP` wrapper must provide a valid 1-to-79-byte PNG profile name, compression method zero, and one independently bounded zlib stream. The decompressed ICC profile is limited to 4 MiB and must be an ICC v2 or v4 scanner/display RGB -matrix profile using XYZ PCS and the D50 PCS illuminant. Zagkit validates the +profile using XYZ PCS and the D50 PCS illuminant. Zagkit validates the declared profile size, header signature, reserved header bytes, rendering intent, bounded aligned tag table, unique required tags, XYZ tag types, and transfer curve types before executing it. The current executable subset accepts the six @@ -77,6 +77,20 @@ function executes from bounded fixed-point coefficients and clips to its declare undefined or unsafe coefficient domains fail before pixels are touched. Matrix columns are converted from D50 PCS into linear sRGB, then encoded through the canonical sRGB transfer. + +The first LUT subset accepts an `A2B0` `lut16Type` with exactly three RGB input +and three PCSXYZ output channels when the profile header selects perceptual +rendering. It does not substitute `A2B0` for another intent. Its matrix must be +the ICC-mandated identity matrix for non-PCS input. Each input and output table +has 2 to 4,096 entries; +the three-dimensional CLUT has 2 to 33 grid points per axis. The tag must end at +the exact computed table boundary before Zagkit allocates owned tables. Pixels +execute through input-table interpolation, fixed-point trilinear CLUT +interpolation, output-table interpolation, PCSXYZ decoding, and the D50-to-sRGB +matrix in that declared order. This supports high-precision LUT16 profiles +without assigning an implementation-specific meaning to ambiguous 8-bit +PCSXYZ LUTs. + An understood `iCCP` profile takes precedence over compatibility `sRGB`, `gAMA`, and `cHRM` chunks. The result records declared rather than assumed profile truth. @@ -92,17 +106,18 @@ headless scale evidence, not native compositor or monitor evidence. The strict deterministic fuzz gate decodes 20,000 arbitrary byte streams, 20,000 structured mutations of a valid PNG, 20,000 arbitrary decompressed ICC profiles, every strict seed prefix, 4,096 general decode/free repetitions, and -4,096 sampled-profile parse/decode/free repetitions on both +4,096 sampled-profile plus 4,096 LUT16 parse/decode/free repetitions on both x86-64 and ARM64. Every result must preserve success or failure ownership invariants. Coverage-guided sanitizer campaigns and a larger published malformed corpus remain additional required evidence. Dimension, encoded-data, decompressed-scanline, output-pixel, palette-index, filter, color-matrix, ICC inflate, ICC tag-table, and arithmetic limits fail -before out-of-bounds access. Monochrome profiles, LUT profiles, device-link profiles, and -unsupported `cICP` metadata fail as unsupported color profiles. Unknown interlace -methods fail before decompression. These paths keep `G3-PNG` open until ICC LUT -execution plus coverage-guided malformed-input evidence land. +before out-of-bounds access. Monochrome profiles, device-link profiles, Lab PCS, +`lutAToBType`, and ambiguous `lut8Type` with PCSXYZ, plus unsupported `cICP` +metadata, fail as unsupported color profiles. Unknown interlace methods fail +before decompression. These paths keep `G3-PNG` open until the remaining ICC +transform families and coverage-guided malformed-input evidence land. Linear-light filtering, wide-gamut output surfaces, mipmapping, high-quality downsampling, image tiling, and GPU upload caches also remain diff --git a/src/render/png_decode.zag b/src/render/png_decode.zag index 06256a6..2b2bebe 100644 --- a/src/render/png_decode.zag +++ b/src/render/png_decode.zag @@ -77,11 +77,24 @@ struct PngTransferCurve { valid: i32, } +struct PngIccLut { + grid_points: i32, + input_entries: i32, + output_entries: i32, + input_tables: ArrayList[i32], + clut: ArrayList[i32], + output_tables: ArrayList[i32], + valid: i32, +} + +struct PngIccLutSample { x: i64, y: i64, z: i64 } + struct PngIccProfileResult { transform: PngColorTransform, red_curve: PngTransferCurve, green_curve: PngTransferCurve, blue_curve: PngTransferCurve, + lut: PngIccLut, valid: i32, } @@ -474,10 +487,33 @@ fn png_transfer_curve_free(curve: *PngTransferCurve) void { if (curve.*.samples.cap > 0) { free[i64](&curve.*.samples); } } +fn png_icc_lut_invalid() PngIccLut { + return PngIccLut{ + .grid_points = 0, .input_entries = 0, .output_entries = 0, + .input_tables = ArrayList[i32]{ + .data = null as *i32, .len = 0, .cap = 0, + }, + .clut = ArrayList[i32]{ + .data = null as *i32, .len = 0, .cap = 0, + }, + .output_tables = ArrayList[i32]{ + .data = null as *i32, .len = 0, .cap = 0, + }, + .valid = 0, + }; +} + +fn png_icc_lut_free(lut: *PngIccLut) void { + if (lut.*.input_tables.cap > 0) { free[i32](&lut.*.input_tables); } + if (lut.*.clut.cap > 0) { free[i32](&lut.*.clut); } + if (lut.*.output_tables.cap > 0) { free[i32](&lut.*.output_tables); } +} + fn png_icc_profile_free(profile: *PngIccProfileResult) void { png_transfer_curve_free(&profile.*.red_curve); png_transfer_curve_free(&profile.*.green_curve); png_transfer_curve_free(&profile.*.blue_curve); + png_icc_lut_free(&profile.*.lut); } fn png_transfer_curve_external() PngTransferCurve { @@ -566,12 +602,69 @@ fn png_icc_curve(bytes: []u8, tag: PngIccTag) PngTransferCurve { return curve; } +fn png_icc_lut16(bytes: []u8, tag: PngIccTag) PngIccLut { + let lut: PngIccLut = png_icc_lut_invalid(); + if (tag.valid == 0 || tag.found == 0 || tag.size < 52 || + png_data_is(bytes, tag.offset, "mft2") == 0 || + png_read_u32_be(bytes, tag.offset + 4) != 0 || + bytes[tag.offset + 8] != 3 || bytes[tag.offset + 9] != 3 || + bytes[tag.offset + 11] != 0) { return lut; } + let grid: i32 = bytes[tag.offset + 10] as i32; + let input_entries: i32 = png_read_u16_be(bytes, tag.offset + 48); + let output_entries: i32 = png_read_u16_be(bytes, tag.offset + 50); + if (grid < 2 || grid > 33 || input_entries < 2 || input_entries > 4096 || + output_entries < 2 || output_entries > 4096) { return lut; } + let matrix_index: i32 = 0; + while (matrix_index < 9) { + let expected: i64 = 0; + if (matrix_index == 0 || matrix_index == 4 || matrix_index == 8) { + expected = 65536; + } + if (png_read_i32_be(bytes, tag.offset + 12 + matrix_index * 4) != expected) { + return lut; + } + matrix_index = matrix_index + 1; + } + let grid64: i64 = grid as i64; + let clut_entries64: i64 = grid64 * grid64 * grid64 * 3; + let expected_size: i64 = 52 + (input_entries as i64) * 3 * 2 + + clut_entries64 * 2 + (output_entries as i64) * 3 * 2; + if (expected_size != tag.size as i64 || clut_entries64 > 107811) { + return lut; + } + lut.input_tables = make[i32](input_entries * 3); + lut.clut = make[i32](clut_entries64 as i32); + lut.output_tables = make[i32](output_entries * 3); + let cursor: i32 = tag.offset + 52; + let index: i32 = 0; + while (index < input_entries * 3) { + push[i32](&lut.input_tables, png_read_u16_be(bytes, cursor)); + cursor = cursor + 2; index = index + 1; + } + index = 0; + while (index < clut_entries64 as i32) { + push[i32](&lut.clut, png_read_u16_be(bytes, cursor)); + cursor = cursor + 2; index = index + 1; + } + index = 0; + while (index < output_entries * 3) { + push[i32](&lut.output_tables, png_read_u16_be(bytes, cursor)); + cursor = cursor + 2; index = index + 1; + } + lut.grid_points = grid; + lut.input_entries = input_entries; + lut.output_entries = output_entries; + lut.valid = 1; + return lut; +} + fn png_icc_matrix_profile(bytes: []u8) PngIccProfileResult { let result: PngIccProfileResult = PngIccProfileResult{ .transform = png_color_transform_identity(), .red_curve = png_transfer_curve_invalid(), .green_curve = png_transfer_curve_invalid(), - .blue_curve = png_transfer_curve_invalid(), .valid = 0, + .blue_curve = png_transfer_curve_invalid(), + .lut = png_icc_lut_invalid(), .valid = 0, }; if (bytes.len < 132 || bytes.len > 4194304 || png_read_u32_be(bytes, 0) != bytes.len as i64 || @@ -595,6 +688,36 @@ fn png_icc_matrix_profile(bytes: []u8) PngIccProfileResult { if (png_f64_abs(illuminant.x - 0.9642) > 0.001 || png_f64_abs(illuminant.y - 1.0) > 0.001 || png_f64_abs(illuminant.z - 0.8249) > 0.001) { return result; } + let lut_tag: PngIccTag = png_icc_tag(bytes, "A2B0"); + if (lut_tag.valid == 0) { return result; } + if (lut_tag.found != 0) { + if (png_read_u32_be(bytes, 64) != 0) { return result; } + let lut: PngIccLut = png_icc_lut16(bytes, lut_tag); + if (lut.valid == 0) { return result; } + let target: PngMatrixF64 = PngMatrixF64{ + .m00 = 0.4360747, .m01 = 0.3850649, .m02 = 0.1430804, + .m10 = 0.2225045, .m11 = 0.7168786, .m12 = 0.0606169, + .m20 = 0.0139322, .m21 = 0.0971045, .m22 = 0.7141733, + }; + let target_inverse: PngMatrixF64Result = png_matrix_inverse(target); + if (target_inverse.valid == 0 || + png_matrix_finite_bounded(target_inverse.matrix, 8.0) == 0) { + png_icc_lut_free(&lut); return result; + } + result.transform = PngColorTransform{ + .m00 = png_quantize_matrix_value(target_inverse.matrix.m00), + .m01 = png_quantize_matrix_value(target_inverse.matrix.m01), + .m02 = png_quantize_matrix_value(target_inverse.matrix.m02), + .m10 = png_quantize_matrix_value(target_inverse.matrix.m10), + .m11 = png_quantize_matrix_value(target_inverse.matrix.m11), + .m12 = png_quantize_matrix_value(target_inverse.matrix.m12), + .m20 = png_quantize_matrix_value(target_inverse.matrix.m20), + .m21 = png_quantize_matrix_value(target_inverse.matrix.m21), + .m22 = png_quantize_matrix_value(target_inverse.matrix.m22), + .active = 1, .valid = 1, + }; + result.lut = lut; result.valid = 1; return result; + } let red_xyz: PngIccVectorResult = png_icc_xyz(bytes, png_icc_tag(bytes, "rXYZ")); let green_xyz: PngIccVectorResult = png_icc_xyz(bytes, png_icc_tag(bytes, "gXYZ")); let blue_xyz: PngIccVectorResult = png_icc_xyz(bytes, png_icc_tag(bytes, "bXYZ")); @@ -666,7 +789,8 @@ fn png_iccp_profile(bytes: []u8, offset: i32, .transform = png_color_transform_identity(), .red_curve = png_transfer_curve_invalid(), .green_curve = png_transfer_curve_invalid(), - .blue_curve = png_transfer_curve_invalid(), .valid = 0, + .blue_curve = png_transfer_curve_invalid(), + .lut = png_icc_lut_invalid(), .valid = 0, }; if (length < 9) { return result; } let name_end: i32 = offset; @@ -1124,19 +1248,119 @@ fn png_transform_linear_channel(a: i64, b: i64, c: i64, return (a * red + b * green + c * blue) / 16777216; } +fn png_icc_lerp_u16(lower: i64, upper: i64, remainder: i64, + denominator: i64) i64 { + return (lower * (denominator - remainder) + upper * remainder + + denominator / 2) / denominator; +} + +fn png_icc_table_sample(table: ArrayList[i32], channel: i32, + entries: i32, value: i64) i64 { + let scaled: i64 = value * ((entries - 1) as i64); + let lower: i32 = (scaled / 65535) as i32; + let remainder: i64 = scaled % 65535; + let upper: i32 = lower + (lower + 1 < entries) as i32; + let offset: i32 = channel * entries; + return png_icc_lerp_u16(table.data[offset + lower] as i64, + table.data[offset + upper] as i64, remainder, 65535); +} + +fn png_icc_clut_value(lut: PngIccLut, red: i32, green: i32, + blue: i32, channel: i32) i64 { + let index: i32 = ((red * lut.grid_points + green) * lut.grid_points + + blue) * 3 + channel; + return lut.clut.data[index] as i64; +} + +fn png_icc_clut_channel(lut: PngIccLut, red: i64, green: i64, + blue: i64, channel: i32) i64 { + let scale: i64 = (lut.grid_points - 1) as i64; + let red_scaled: i64 = red * scale; + let green_scaled: i64 = green * scale; + let blue_scaled: i64 = blue * scale; + let red_lower: i32 = (red_scaled / 65535) as i32; + let green_lower: i32 = (green_scaled / 65535) as i32; + let blue_lower: i32 = (blue_scaled / 65535) as i32; + let red_upper: i32 = red_lower + + (red_lower + 1 < lut.grid_points) as i32; + let green_upper: i32 = green_lower + + (green_lower + 1 < lut.grid_points) as i32; + let blue_upper: i32 = blue_lower + + (blue_lower + 1 < lut.grid_points) as i32; + let blue00: i64 = png_icc_lerp_u16( + png_icc_clut_value(lut, red_lower, green_lower, blue_lower, channel), + png_icc_clut_value(lut, red_lower, green_lower, blue_upper, channel), + blue_scaled % 65535, 65535 + ); + let blue01: i64 = png_icc_lerp_u16( + png_icc_clut_value(lut, red_lower, green_upper, blue_lower, channel), + png_icc_clut_value(lut, red_lower, green_upper, blue_upper, channel), + blue_scaled % 65535, 65535 + ); + let blue10: i64 = png_icc_lerp_u16( + png_icc_clut_value(lut, red_upper, green_lower, blue_lower, channel), + png_icc_clut_value(lut, red_upper, green_lower, blue_upper, channel), + blue_scaled % 65535, 65535 + ); + let blue11: i64 = png_icc_lerp_u16( + png_icc_clut_value(lut, red_upper, green_upper, blue_lower, channel), + png_icc_clut_value(lut, red_upper, green_upper, blue_upper, channel), + blue_scaled % 65535, 65535 + ); + let green0: i64 = png_icc_lerp_u16( + blue00, blue01, green_scaled % 65535, 65535 + ); + let green1: i64 = png_icc_lerp_u16( + blue10, blue11, green_scaled % 65535, 65535 + ); + return png_icc_lerp_u16( + green0, green1, red_scaled % 65535, 65535 + ); +} + +fn png_icc_lut_sample(lut: PngIccLut, red: u8, green: u8, + blue: u8) PngIccLutSample { + let input_red: i64 = png_icc_table_sample( + lut.input_tables, 0, lut.input_entries, (red as i64) * 257 + ); + let input_green: i64 = png_icc_table_sample( + lut.input_tables, 1, lut.input_entries, (green as i64) * 257 + ); + let input_blue: i64 = png_icc_table_sample( + lut.input_tables, 2, lut.input_entries, (blue as i64) * 257 + ); + let pcs_x: i64 = png_icc_table_sample( + lut.output_tables, 0, lut.output_entries, + png_icc_clut_channel(lut, input_red, input_green, input_blue, 0) + ); + let pcs_y: i64 = png_icc_table_sample( + lut.output_tables, 1, lut.output_entries, + png_icc_clut_channel(lut, input_red, input_green, input_blue, 1) + ); + let pcs_z: i64 = png_icc_table_sample( + lut.output_tables, 2, lut.output_entries, + png_icc_clut_channel(lut, input_red, input_green, input_blue, 2) + ); + return PngIccLutSample{ + .x = (pcs_x * 1073741824 + 16384) / 32768, + .y = (pcs_y * 1073741824 + 16384) / 32768, + .z = (pcs_z * 1073741824 + 16384) / 32768, + }; +} + fn png_expand_rgba(raw: []u8, width: i32, height: i32, row_bytes: i32, bit_depth: i32, color_type: i32, palette: ArrayList[u8], transparency: ArrayList[u8], source_gamma: i64, color_transform: PngColorTransform, red_curve: PngTransferCurve, green_curve: PngTransferCurve, - blue_curve: PngTransferCurve, + blue_curve: PngTransferCurve, lut: PngIccLut, profile_assumed: i32) PngDecodeResult { let pixels: ArrayList[u8] = make[u8](width * height * 4); let gamma_table: ArrayList[u8] = make[u8](256); let red_linear: ArrayList[i64] = make[i64](256); let green_linear: ArrayList[i64] = make[i64](256); let blue_linear: ArrayList[i64] = make[i64](256); - if (color_transform.active != 0) { + if (color_transform.active != 0 && lut.valid == 0) { let linear_sample: i32 = 0; while (linear_sample < 256) { push[i64](&red_linear, png_transfer_sample_to_linear_q30( @@ -1219,7 +1443,23 @@ fn png_expand_rgba(raw: []u8, width: i32, height: i32, row_bytes: i32, blue = png_sample_u8(png_sample(row, x * 4 + 2, bit_depth), bit_depth); alpha = png_sample_u8(png_sample(row, x * 4 + 3, bit_depth), bit_depth); } - if (red_linear.len == 256) { + if (lut.valid != 0) { + let pcs: PngIccLutSample = png_icc_lut_sample( + lut, red, green, blue + ); + red = png_linear_q30_to_srgb(png_transform_linear_channel( + color_transform.m00, color_transform.m01, color_transform.m02, + pcs.x, pcs.y, pcs.z + )); + green = png_linear_q30_to_srgb(png_transform_linear_channel( + color_transform.m10, color_transform.m11, color_transform.m12, + pcs.x, pcs.y, pcs.z + )); + blue = png_linear_q30_to_srgb(png_transform_linear_channel( + color_transform.m20, color_transform.m21, color_transform.m22, + pcs.x, pcs.y, pcs.z + )); + } else if (red_linear.len == 256) { let linear_red: i64 = red_linear.data[red as i32]; let linear_green: i64 = green_linear.data[green as i32]; let linear_blue: i64 = blue_linear.data[blue as i32]; @@ -1521,7 +1761,8 @@ fn png_decode(bytes: []u8) PngDecodeResult { .transform = png_color_transform_identity(), .red_curve = png_transfer_curve_invalid(), .green_curve = png_transfer_curve_invalid(), - .blue_curve = png_transfer_curve_invalid(), .valid = 0, + .blue_curve = png_transfer_curve_invalid(), + .lut = png_icc_lut_invalid(), .valid = 0, }; if (saw_icc != 0) { icc_profile = png_iccp_profile(bytes, icc_data_offset, icc_data_length); @@ -1576,7 +1817,7 @@ fn png_decode(bytes: []u8) PngDecodeResult { scanlines.bytes.data[0..scanlines.bytes.len], width, height, row_bytes64 as i32, bit_depth, color_type, palette, transparency, conversion_gamma, effective_transform, effective_red_curve, - effective_green_curve, effective_blue_curve, + effective_green_curve, effective_blue_curve, icc_profile.lut, (saw_srgb == 0 && saw_icc == 0 && !(source_gamma != 0 && saw_chrm != 0)) as i32 ); diff --git a/tests/png_decode_contract.zag b/tests/png_decode_contract.zag index cc5536b..9f5eb94 100644 --- a/tests/png_decode_contract.zag +++ b/tests/png_decode_contract.zag @@ -207,6 +207,12 @@ fn fixture_write_signature(bytes: *ArrayList[u8], offset: i32, while (i < 4) { bytes.*.data[offset + i] = signature[i]; i = i + 1; } } +fn fixture_write_u16_be(bytes: *ArrayList[u8], offset: i32, + value: i32) void { + bytes.*.data[offset] = ((value >> 8) & 255) as u8; + bytes.*.data[offset + 1] = (value & 255) as u8; +} + fn fixture_icc_tag(bytes: *ArrayList[u8], entry: i32, signature: []u8, data_offset: i32, data_size: i32) void { fixture_write_signature(bytes, entry, signature); @@ -337,6 +343,72 @@ fn fixture_matrix_icc(kind: i32) ArrayList[u8] { return bytes; } +fn fixture_lut16_icc(kind: i32) ArrayList[u8] { + let bytes: ArrayList[u8] = make[u8](268); + let i: i32 = 0; + while (i < 268) { push[u8](&bytes, 0); i = i + 1; } + fixture_write_u32_be(&bytes, 0, 268); + bytes.data[8] = 4; + fixture_write_signature(&bytes, 12, "mntr"); + fixture_write_signature(&bytes, 16, "RGB "); + fixture_write_signature(&bytes, 20, "XYZ "); + fixture_write_signature(&bytes, 36, "acsp"); + fixture_write_u32_be(&bytes, 68, 63190); + fixture_write_u32_be(&bytes, 72, 65536); + fixture_write_u32_be(&bytes, 76, 54061); + fixture_write_u32_be(&bytes, 128, 1); + fixture_icc_tag(&bytes, 132, "A2B0", 144, 124); + fixture_write_signature(&bytes, 144, "mft2"); + bytes.data[152] = 3; bytes.data[153] = 3; bytes.data[154] = 2; + fixture_write_u32_be(&bytes, 156, 65536); + fixture_write_u32_be(&bytes, 172, 65536); + fixture_write_u32_be(&bytes, 188, 65536); + fixture_write_u16_be(&bytes, 192, 2); + fixture_write_u16_be(&bytes, 194, 2); + let input_offset: i32 = 196; + let channel: i32 = 0; + while (channel < 3) { + fixture_write_u16_be(&bytes, input_offset + channel * 4, 0); + fixture_write_u16_be(&bytes, input_offset + channel * 4 + 2, 65535); + channel = channel + 1; + } + let clut_offset: i32 = 208; + let red: i32 = 0; + let clut_index: i32 = 0; + while (red < 2) { + let green: i32 = 0; + while (green < 2) { + let blue: i32 = 0; + while (blue < 2) { + fixture_write_u16_be(&bytes, clut_offset + clut_index * 2, + red * 14289 + green * 12618 + blue * 4688); + fixture_write_u16_be(&bytes, clut_offset + clut_index * 2 + 2, + red * 7291 + green * 23491 + blue * 1986); + fixture_write_u16_be(&bytes, clut_offset + clut_index * 2 + 4, + red * 457 + green * 3182 + blue * 23402); + clut_index = clut_index + 3; + blue = blue + 1; + } + green = green + 1; + } + red = red + 1; + } + let output_offset: i32 = 256; + channel = 0; + while (channel < 3) { + fixture_write_u16_be(&bytes, output_offset + channel * 4, 0); + fixture_write_u16_be(&bytes, output_offset + channel * 4 + 2, 65535); + channel = channel + 1; + } + if (kind == 22) { bytes.data[154] = 34; } + if (kind == 23) { fixture_write_u32_be(&bytes, 160, 1); } + if (kind == 24) { fixture_write_signature(&bytes, 144, "mft1"); } + if (kind == 25) { fixture_write_u32_be(&bytes, 140, 122); } + if (kind == 26) { fixture_write_u16_be(&bytes, 192, 1); } + if (kind == 27) { fixture_write_u32_be(&bytes, 64, 1); } + return bytes; +} + fn fixture_profile_png(gamma_count: i32, chromaticity_kind: i32, gamma_value: i64, include_srgb: i32, icc_kind: i32) PngEncodeResult { let compressed: ZlibStoreEncodeResult = zlib_store_encode("\x00\x11\x22\x33\xff"); @@ -364,6 +436,9 @@ fn fixture_profile_png(gamma_count: i32, chromaticity_kind: i32, png_write_chunk(&output, "cICP", "\x01\x0d\x00\x01"); } else if (icc_kind != 0) { let profile: ArrayList[u8] = fixture_matrix_icc(icc_kind); + if (icc_kind >= 21) { + free[u8](&profile); profile = fixture_lut16_icc(icc_kind); + } let compressed_profile: ZlibStoreEncodeResult = zlib_store_encode( profile.data[0..profile.len] ); @@ -768,6 +843,96 @@ fn color_profile_contract(state: *TestState) void { "4096 sampled ICC parse decode and cleanup cycles preserve exact output"); png_encode_free(&sampled_fixture); + let lut16_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 21); + let lut16: PngDecodeResult = png_decode( + lut16_fixture.bytes.data[0..lut16_fixture.bytes.len] + ); + expect(state, lut16.error == PngDecodeError.none && + lut16.profile_assumed == 0 && lut16.pixels.data[0] == 73 && + lut16.pixels.data[1] == 102 && lut16.pixels.data[2] == 124, + "ICC lut16 input tables CLUT and output tables execute in declared order"); + png_decode_free(&lut16); + let lut16_repeat_valid: i32 = 1; + let lut16_repeat: i32 = 0; + while (lut16_repeat < 4096 && lut16_repeat_valid != 0) { + let repeated_lut16: PngDecodeResult = png_decode( + lut16_fixture.bytes.data[0..lut16_fixture.bytes.len] + ); + if (repeated_lut16.error != PngDecodeError.none || + repeated_lut16.pixels.data[0] != 73 || + repeated_lut16.pixels.data[1] != 102 || + repeated_lut16.pixels.data[2] != 124) { + lut16_repeat_valid = 0; + } + png_decode_free(&repeated_lut16); + lut16_repeat = lut16_repeat + 1; + } + expect(state, lut16_repeat_valid != 0 && lut16_repeat == 4096, + "4096 ICC lut16 parse decode and cleanup cycles preserve exact output"); + png_encode_free(&lut16_fixture); + + let bad_lut_grid_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 22 + ); + let bad_lut_grid: PngDecodeResult = png_decode( + bad_lut_grid_fixture.bytes.data[0..bad_lut_grid_fixture.bytes.len] + ); + expect(state, bad_lut_grid.error == PngDecodeError.unsupported_color_profile, + "ICC lut16 grids above the bounded three dimensional ceiling fail closed"); + png_decode_free(&bad_lut_grid); png_encode_free(&bad_lut_grid_fixture); + + let bad_lut_matrix_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 23 + ); + let bad_lut_matrix: PngDecodeResult = png_decode( + bad_lut_matrix_fixture.bytes.data[0..bad_lut_matrix_fixture.bytes.len] + ); + expect(state, bad_lut_matrix.error == PngDecodeError.unsupported_color_profile, + "RGB to PCS lut16 profiles require the ICC mandated identity matrix"); + png_decode_free(&bad_lut_matrix); png_encode_free(&bad_lut_matrix_fixture); + + let ambiguous_lut8_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 24 + ); + let ambiguous_lut8: PngDecodeResult = png_decode( + ambiguous_lut8_fixture.bytes.data[0..ambiguous_lut8_fixture.bytes.len] + ); + expect(state, ambiguous_lut8.error == PngDecodeError.unsupported_color_profile, + "ambiguous lut8 PCSXYZ profiles remain an explicit unsupported path"); + png_decode_free(&ambiguous_lut8); png_encode_free(&ambiguous_lut8_fixture); + + let truncated_lut_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 25 + ); + let truncated_lut: PngDecodeResult = png_decode( + truncated_lut_fixture.bytes.data[0..truncated_lut_fixture.bytes.len] + ); + expect(state, truncated_lut.error == PngDecodeError.unsupported_color_profile, + "ICC lut16 tables must consume exactly their declared bounded tag"); + png_decode_free(&truncated_lut); png_encode_free(&truncated_lut_fixture); + + let short_lut_table_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 26 + ); + let short_lut_table: PngDecodeResult = png_decode( + short_lut_table_fixture.bytes.data[0..short_lut_table_fixture.bytes.len] + ); + expect(state, short_lut_table.error == PngDecodeError.unsupported_color_profile, + "ICC lut16 one dimensional tables require at least two entries"); + png_decode_free(&short_lut_table); png_encode_free(&short_lut_table_fixture); + + let wrong_lut_intent_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 27 + ); + let wrong_lut_intent: PngDecodeResult = png_decode( + wrong_lut_intent_fixture.bytes.data[0..wrong_lut_intent_fixture.bytes.len] + ); + expect(state, + wrong_lut_intent.error == PngDecodeError.unsupported_color_profile, + "A2B0 LUT execution cannot impersonate a non-perceptual rendering intent"); + png_decode_free(&wrong_lut_intent); + png_encode_free(&wrong_lut_intent_fixture); + let oversized_curve_fixture: PngEncodeResult = fixture_profile_png( 0, 0, 0, 0, 19 ); diff --git a/tests/png_decode_fuzz.zag b/tests/png_decode_fuzz.zag index ef017df..f3376ae 100644 --- a/tests/png_decode_fuzz.zag +++ b/tests/png_decode_fuzz.zag @@ -120,13 +120,31 @@ fn icc_profile_contract(state: *FuzzState) void { bytes.data[0..bytes.len] ); if ((profile.valid != 0 && profile.valid != 1) || + (profile.lut.valid != 0 && profile.lut.valid != 1) || (profile.valid != 0 && (profile.transform.valid == 0 || profile.transform.active == 0 || - profile.red_curve.valid == 0 || profile.green_curve.valid == 0 || - profile.blue_curve.valid == 0 || - profile.red_curve.kind < 1 || profile.red_curve.kind > 6 || - profile.green_curve.kind < 1 || profile.green_curve.kind > 6 || - profile.blue_curve.kind < 1 || profile.blue_curve.kind > 6))) { + (profile.lut.valid == 0 && ( + profile.red_curve.valid == 0 || + profile.green_curve.valid == 0 || + profile.blue_curve.valid == 0 || + profile.red_curve.kind < 1 || profile.red_curve.kind > 6 || + profile.green_curve.kind < 1 || + profile.green_curve.kind > 6 || + profile.blue_curve.kind < 1 || + profile.blue_curve.kind > 6)) || + (profile.lut.valid != 0 && ( + profile.lut.grid_points < 2 || + profile.lut.grid_points > 33 || + profile.lut.input_entries < 2 || + profile.lut.input_entries > 4096 || + profile.lut.output_entries < 2 || + profile.lut.output_entries > 4096 || + profile.lut.input_tables.len != + profile.lut.input_entries * 3 || + profile.lut.clut.len != profile.lut.grid_points * + profile.lut.grid_points * profile.lut.grid_points * 3 || + profile.lut.output_tables.len != + profile.lut.output_entries * 3))))) { valid = 0; } png_icc_profile_free(&profile); From 356db1e66722b531888f6da78f46ddb17fd41f48 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 14:44:14 -0700 Subject: [PATCH 042/103] Execute bounded ICC A to B LUTs --- README.md | 10 +- docs/architecture/images.md | 22 +++- src/render/png_decode.zag | 211 ++++++++++++++++++++++++++++++++-- tests/png_decode_contract.zag | 140 ++++++++++++++++++++++ tests/png_decode_fuzz.zag | 13 ++- 5 files changed, 372 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 1357831..458bbba 100644 --- a/README.md +++ b/README.md @@ -189,10 +189,14 @@ v2/v4 RGB matrix profiles in XYZ PCS with shared identity, gamma, or ICC parametric curve types 0 through 4 or sampled curves of up to 4,096 entries per channel. It also executes bounded RGB-to-XYZ `A2B0` `lut16Type` profiles with owned input tables, a trilinearly interpolated 3D CLUT, and output tables. +The first `lutAToBType` path executes the permitted A-curves, CLUT, B-curves +combination with independent embedded curves, per-axis grid sizes, and 8-bit or +16-bit CLUT precision. Malformed wrappers and profile or table bounds fail explicitly. A deterministic -60,000-input PNG/ICC validation harness plus 4,096 general, sampled-profile, and -LUT16 cleanup cycles passes on both architectures. `lutAToBType`, Lab PCS, and -coverage-guided sanitizer evidence remain open, so the `G3-PNG` +60,000-input PNG/ICC validation harness plus 4,096 general, sampled-profile, +LUT16, and `lutAToBType` cleanup cycles passes on both architectures. The +`lutAToBType` matrix combinations, Lab PCS, and coverage-guided sanitizer +evidence remain open, so the `G3-PNG` completion box remains deliberately unchecked. See the [image and PNG ingestion contract](docs/architecture/images.md). diff --git a/docs/architecture/images.md b/docs/architecture/images.md index b3307ab..5059308 100644 --- a/docs/architecture/images.md +++ b/docs/architecture/images.md @@ -91,6 +91,16 @@ matrix in that declared order. This supports high-precision LUT16 profiles without assigning an implementation-specific meaning to ambiguous 8-bit PCSXYZ LUTs. +The first `lutAToBType` subset accepts the specification's A-curves, CLUT, +B-curves combination. Each of the three A and B curves is independently parsed +as an embedded identity, gamma, sampled, or parametric curve and normalized to +an owned 256-entry table. The CLUT permits independent 2-to-33-point red, green, +and blue axes and either 8-bit or 16-bit data. Offsets must be aligned, bounded +by the exact element boundary, and padded with canonical zeroes; only the +specification-permitted sharing of complete curve sets may overlap. The B-only +and M-curves, matrix, B-curves combinations remain explicit +unsupported paths until their distinct execution stages have contracts. + An understood `iCCP` profile takes precedence over compatibility `sRGB`, `gAMA`, and `cHRM` chunks. The result records declared rather than assumed profile truth. @@ -106,7 +116,8 @@ headless scale evidence, not native compositor or monitor evidence. The strict deterministic fuzz gate decodes 20,000 arbitrary byte streams, 20,000 structured mutations of a valid PNG, 20,000 arbitrary decompressed ICC profiles, every strict seed prefix, 4,096 general decode/free repetitions, and -4,096 sampled-profile plus 4,096 LUT16 parse/decode/free repetitions on both +4,096 sampled-profile, 4,096 LUT16, and 4,096 `lutAToBType` +parse/decode/free repetitions on both x86-64 and ARM64. Every result must preserve success or failure ownership invariants. Coverage-guided sanitizer campaigns and a larger published malformed corpus remain additional required evidence. @@ -114,10 +125,11 @@ and a larger published malformed corpus remain additional required evidence. Dimension, encoded-data, decompressed-scanline, output-pixel, palette-index, filter, color-matrix, ICC inflate, ICC tag-table, and arithmetic limits fail before out-of-bounds access. Monochrome profiles, device-link profiles, Lab PCS, -`lutAToBType`, and ambiguous `lut8Type` with PCSXYZ, plus unsupported `cICP` -metadata, fail as unsupported color profiles. Unknown interlace methods fail -before decompression. These paths keep `G3-PNG` open until the remaining ICC -transform families and coverage-guided malformed-input evidence land. +the remaining `lutAToBType` combinations, and ambiguous `lut8Type` with PCSXYZ, +plus unsupported `cICP` metadata, fail as unsupported color profiles. Unknown +interlace methods fail before decompression. These paths keep `G3-PNG` open until +the remaining ICC transform families and coverage-guided malformed-input +evidence land. Linear-light filtering, wide-gamut output surfaces, mipmapping, high-quality downsampling, image tiling, and GPU upload caches also remain diff --git a/src/render/png_decode.zag b/src/render/png_decode.zag index 2b2bebe..03474b1 100644 --- a/src/render/png_decode.zag +++ b/src/render/png_decode.zag @@ -78,7 +78,9 @@ struct PngTransferCurve { } struct PngIccLut { - grid_points: i32, + red_grid_points: i32, + green_grid_points: i32, + blue_grid_points: i32, input_entries: i32, output_entries: i32, input_tables: ArrayList[i32], @@ -89,6 +91,8 @@ struct PngIccLut { struct PngIccLutSample { x: i64, y: i64, z: i64 } +struct PngIccCurveTableResult { table: ArrayList[i32], valid: i32 } + struct PngIccProfileResult { transform: PngColorTransform, red_curve: PngTransferCurve, @@ -489,7 +493,8 @@ fn png_transfer_curve_free(curve: *PngTransferCurve) void { fn png_icc_lut_invalid() PngIccLut { return PngIccLut{ - .grid_points = 0, .input_entries = 0, .output_entries = 0, + .red_grid_points = 0, .green_grid_points = 0, .blue_grid_points = 0, + .input_entries = 0, .output_entries = 0, .input_tables = ArrayList[i32]{ .data = null as *i32, .len = 0, .cap = 0, }, @@ -602,6 +607,84 @@ fn png_icc_curve(bytes: []u8, tag: PngIccTag) PngTransferCurve { return curve; } +fn png_icc_curve_size(bytes: []u8, offset: i32, limit: i32) i32 { + if (offset < 0 || limit - offset < 12 || + png_read_u32_be(bytes, offset + 4) != 0) { return 0; } + if (png_data_is(bytes, offset, "curv") != 0) { + let count: i64 = png_read_u32_be(bytes, offset + 8); + if (count > 4096 || 12 + count * 2 > (limit - offset) as i64) { + return 0; + } + return (12 + count * 2) as i32; + } + if (png_data_is(bytes, offset, "para") != 0) { + let function_type: i32 = png_read_u16_be(bytes, offset + 8); + let parameter_count: i32 = 0; + if (function_type == 0) { parameter_count = 1; } + else if (function_type == 1) { parameter_count = 3; } + else if (function_type == 2) { parameter_count = 4; } + else if (function_type == 3) { parameter_count = 5; } + else if (function_type == 4) { parameter_count = 7; } + else { return 0; } + if (png_read_u16_be(bytes, offset + 10) != 0 || + 12 + parameter_count * 4 > limit - offset) { return 0; } + return 12 + parameter_count * 4; + } + return 0; +} + +fn png_icc_curve_tables(bytes: []u8, base: i32, start: i32, + limit: i32) PngIccCurveTableResult { + let result: PngIccCurveTableResult = PngIccCurveTableResult{ + .table = make[i32](768), .valid = 0, + }; + let cursor: i32 = start; + let channel: i32 = 0; + while (channel < 3) { + let size: i32 = png_icc_curve_size(bytes, base + cursor, base + limit); + if (size == 0) { free[i32](&result.table); return result; } + let curve: PngTransferCurve = png_icc_curve(bytes, PngIccTag{ + .offset = base + cursor, .size = size, .found = 1, .valid = 1, + }); + if (curve.valid == 0) { + png_transfer_curve_free(&curve); + free[i32](&result.table); return result; + } + let sample: i32 = 0; + while (sample < 256) { + let q30: i64 = png_transfer_sample_to_linear_q30( + sample as u8, 0, curve + ); + push[i32](&result.table, + ((q30 * 65535 + 536870912) / 1073741824) as i32); + sample = sample + 1; + } + png_transfer_curve_free(&curve); + let aligned: i32 = (size + 3) & (0 - 4); + let padding: i32 = size; + while (padding < aligned) { + if (cursor + padding >= limit || + bytes[base + cursor + padding] != 0) { + free[i32](&result.table); return result; + } + padding = padding + 1; + } + cursor = cursor + aligned; + channel = channel + 1; + } + if (cursor != limit) { free[i32](&result.table); return result; } + result.valid = 1; return result; +} + +fn png_icc_element_limit(start: i32, first: i32, second: i32, + third: i32, tag_size: i32) i32 { + let limit: i32 = tag_size; + if (first > start && first < limit) { limit = first; } + if (second > start && second < limit) { limit = second; } + if (third > start && third < limit) { limit = third; } + return limit; +} + fn png_icc_lut16(bytes: []u8, tag: PngIccTag) PngIccLut { let lut: PngIccLut = png_icc_lut_invalid(); if (tag.valid == 0 || tag.found == 0 || tag.size < 52 || @@ -651,13 +734,115 @@ fn png_icc_lut16(bytes: []u8, tag: PngIccTag) PngIccLut { push[i32](&lut.output_tables, png_read_u16_be(bytes, cursor)); cursor = cursor + 2; index = index + 1; } - lut.grid_points = grid; + lut.red_grid_points = grid; + lut.green_grid_points = grid; + lut.blue_grid_points = grid; lut.input_entries = input_entries; lut.output_entries = output_entries; lut.valid = 1; return lut; } +fn png_icc_lut_atob(bytes: []u8, tag: PngIccTag) PngIccLut { + let lut: PngIccLut = png_icc_lut_invalid(); + if (tag.valid == 0 || tag.found == 0 || tag.size < 32 || + png_data_is(bytes, tag.offset, "mAB ") == 0 || + png_read_u32_be(bytes, tag.offset + 4) != 0 || + bytes[tag.offset + 8] != 3 || bytes[tag.offset + 9] != 3 || + bytes[tag.offset + 10] != 0 || bytes[tag.offset + 11] != 0) { + return lut; + } + let b_offset: i32 = png_read_u32_be(bytes, tag.offset + 12) as i32; + let matrix_offset: i32 = png_read_u32_be(bytes, tag.offset + 16) as i32; + let m_offset: i32 = png_read_u32_be(bytes, tag.offset + 20) as i32; + let clut_offset: i32 = png_read_u32_be(bytes, tag.offset + 24) as i32; + let a_offset: i32 = png_read_u32_be(bytes, tag.offset + 28) as i32; + if (a_offset < 32 || b_offset < 32 || clut_offset < 32 || + a_offset % 4 != 0 || b_offset % 4 != 0 || clut_offset % 4 != 0 || + a_offset >= tag.size || b_offset >= tag.size || + clut_offset >= tag.size || clut_offset == a_offset || + clut_offset == b_offset || matrix_offset != 0 || m_offset != 0) { + return lut; + } + let a_limit: i32 = png_icc_element_limit( + a_offset, b_offset, clut_offset, 0, tag.size + ); + let b_limit: i32 = png_icc_element_limit( + b_offset, a_offset, clut_offset, 0, tag.size + ); + let clut_limit: i32 = png_icc_element_limit( + clut_offset, a_offset, b_offset, 0, tag.size + ); + let a_curves: PngIccCurveTableResult = png_icc_curve_tables( + bytes, tag.offset, a_offset, a_limit + ); + if (a_curves.valid == 0) { return lut; } + let b_curves: PngIccCurveTableResult = png_icc_curve_tables( + bytes, tag.offset, b_offset, b_limit + ); + if (b_curves.valid == 0) { + free[i32](&a_curves.table); return lut; + } + if (clut_limit - clut_offset < 20) { + free[i32](&a_curves.table); free[i32](&b_curves.table); return lut; + } + let red_grid: i32 = bytes[tag.offset + clut_offset] as i32; + let green_grid: i32 = bytes[tag.offset + clut_offset + 1] as i32; + let blue_grid: i32 = bytes[tag.offset + clut_offset + 2] as i32; + if (red_grid < 2 || red_grid > 33 || green_grid < 2 || green_grid > 33 || + blue_grid < 2 || blue_grid > 33) { + free[i32](&a_curves.table); free[i32](&b_curves.table); return lut; + } + let unused: i32 = 3; + while (unused < 16) { + if (bytes[tag.offset + clut_offset + unused] != 0) { + free[i32](&a_curves.table); free[i32](&b_curves.table); return lut; + } + unused = unused + 1; + } + let precision: i32 = bytes[tag.offset + clut_offset + 16] as i32; + if ((precision != 1 && precision != 2) || + bytes[tag.offset + clut_offset + 17] != 0 || + bytes[tag.offset + clut_offset + 18] != 0 || + bytes[tag.offset + clut_offset + 19] != 0) { + free[i32](&a_curves.table); free[i32](&b_curves.table); return lut; + } + let entries64: i64 = (red_grid as i64) * (green_grid as i64) * + (blue_grid as i64) * 3; + let data_end: i32 = clut_offset + 20 + (entries64 as i32) * precision; + let aligned_end: i32 = (data_end + 3) & (0 - 4); + if (entries64 > 107811 || aligned_end != clut_limit) { + free[i32](&a_curves.table); free[i32](&b_curves.table); return lut; + } + let padding: i32 = data_end; + while (padding < aligned_end) { + if (bytes[tag.offset + padding] != 0) { + free[i32](&a_curves.table); free[i32](&b_curves.table); return lut; + } + padding = padding + 1; + } + lut.clut = make[i32](entries64 as i32); + let cursor: i32 = tag.offset + clut_offset + 20; + let index: i32 = 0; + while (index < entries64 as i32) { + if (precision == 1) { + push[i32](&lut.clut, (bytes[cursor] as i32) * 257); + cursor = cursor + 1; + } else { + push[i32](&lut.clut, png_read_u16_be(bytes, cursor)); + cursor = cursor + 2; + } + index = index + 1; + } + lut.input_tables = a_curves.table; + lut.output_tables = b_curves.table; + lut.input_entries = 256; lut.output_entries = 256; + lut.red_grid_points = red_grid; + lut.green_grid_points = green_grid; + lut.blue_grid_points = blue_grid; + lut.valid = 1; return lut; +} + fn png_icc_matrix_profile(bytes: []u8) PngIccProfileResult { let result: PngIccProfileResult = PngIccProfileResult{ .transform = png_color_transform_identity(), @@ -693,6 +878,9 @@ fn png_icc_matrix_profile(bytes: []u8) PngIccProfileResult { if (lut_tag.found != 0) { if (png_read_u32_be(bytes, 64) != 0) { return result; } let lut: PngIccLut = png_icc_lut16(bytes, lut_tag); + if (png_data_is(bytes, lut_tag.offset, "mAB ") != 0) { + lut = png_icc_lut_atob(bytes, lut_tag); + } if (lut.valid == 0) { return result; } let target: PngMatrixF64 = PngMatrixF64{ .m00 = 0.4360747, .m01 = 0.3850649, .m02 = 0.1430804, @@ -1267,26 +1455,25 @@ fn png_icc_table_sample(table: ArrayList[i32], channel: i32, fn png_icc_clut_value(lut: PngIccLut, red: i32, green: i32, blue: i32, channel: i32) i64 { - let index: i32 = ((red * lut.grid_points + green) * lut.grid_points + - blue) * 3 + channel; + let index: i32 = ((red * lut.green_grid_points + green) * + lut.blue_grid_points + blue) * 3 + channel; return lut.clut.data[index] as i64; } fn png_icc_clut_channel(lut: PngIccLut, red: i64, green: i64, blue: i64, channel: i32) i64 { - let scale: i64 = (lut.grid_points - 1) as i64; - let red_scaled: i64 = red * scale; - let green_scaled: i64 = green * scale; - let blue_scaled: i64 = blue * scale; + let red_scaled: i64 = red * ((lut.red_grid_points - 1) as i64); + let green_scaled: i64 = green * ((lut.green_grid_points - 1) as i64); + let blue_scaled: i64 = blue * ((lut.blue_grid_points - 1) as i64); let red_lower: i32 = (red_scaled / 65535) as i32; let green_lower: i32 = (green_scaled / 65535) as i32; let blue_lower: i32 = (blue_scaled / 65535) as i32; let red_upper: i32 = red_lower + - (red_lower + 1 < lut.grid_points) as i32; + (red_lower + 1 < lut.red_grid_points) as i32; let green_upper: i32 = green_lower + - (green_lower + 1 < lut.grid_points) as i32; + (green_lower + 1 < lut.green_grid_points) as i32; let blue_upper: i32 = blue_lower + - (blue_lower + 1 < lut.grid_points) as i32; + (blue_lower + 1 < lut.blue_grid_points) as i32; let blue00: i64 = png_icc_lerp_u16( png_icc_clut_value(lut, red_lower, green_lower, blue_lower, channel), png_icc_clut_value(lut, red_lower, green_lower, blue_upper, channel), diff --git a/tests/png_decode_contract.zag b/tests/png_decode_contract.zag index 9f5eb94..32d146a 100644 --- a/tests/png_decode_contract.zag +++ b/tests/png_decode_contract.zag @@ -409,6 +409,66 @@ fn fixture_lut16_icc(kind: i32) ArrayList[u8] { return bytes; } +fn fixture_mab_icc(kind: i32) ArrayList[u8] { + let bytes: ArrayList[u8] = make[u8](340); + let i: i32 = 0; + while (i < 340) { push[u8](&bytes, 0); i = i + 1; } + fixture_write_u32_be(&bytes, 0, 340); + bytes.data[8] = 4; + fixture_write_signature(&bytes, 12, "mntr"); + fixture_write_signature(&bytes, 16, "RGB "); + fixture_write_signature(&bytes, 20, "XYZ "); + fixture_write_signature(&bytes, 36, "acsp"); + fixture_write_u32_be(&bytes, 68, 63190); + fixture_write_u32_be(&bytes, 72, 65536); + fixture_write_u32_be(&bytes, 76, 54061); + fixture_write_u32_be(&bytes, 128, 1); + fixture_icc_tag(&bytes, 132, "A2B0", 144, 196); + fixture_write_signature(&bytes, 144, "mAB "); + bytes.data[152] = 3; bytes.data[153] = 3; + fixture_write_u32_be(&bytes, 156, 160); + fixture_write_u32_be(&bytes, 168, 68); + fixture_write_u32_be(&bytes, 172, 32); + let curve: i32 = 0; + while (curve < 3) { + fixture_write_signature(&bytes, 176 + curve * 12, "curv"); + curve = curve + 1; + } + let clut: i32 = 212; + bytes.data[clut] = 2; bytes.data[clut + 1] = 3; + bytes.data[clut + 2] = 2; bytes.data[clut + 16] = 2; + let red: i32 = 0; + let clut_index: i32 = 0; + while (red < 2) { + let green: i32 = 0; + while (green < 3) { + let blue: i32 = 0; + while (blue < 2) { + fixture_write_u16_be(&bytes, clut + 20 + clut_index * 2, + red * 14289 + (green * 12618 + 1) / 2 + blue * 4688); + fixture_write_u16_be(&bytes, clut + 22 + clut_index * 2, + red * 7291 + (green * 23491 + 1) / 2 + blue * 1986); + fixture_write_u16_be(&bytes, clut + 24 + clut_index * 2, + red * 457 + (green * 3182 + 1) / 2 + blue * 23402); + clut_index = clut_index + 3; blue = blue + 1; + } + green = green + 1; + } + red = red + 1; + } + curve = 0; + while (curve < 3) { + fixture_write_signature(&bytes, 304 + curve * 12, "curv"); + curve = curve + 1; + } + if (kind == 29) { bytes.data[clut + 3] = 1; } + if (kind == 30) { fixture_write_u32_be(&bytes, 156, 0); } + if (kind == 31) { fixture_write_signature(&bytes, 176, "bad!"); } + if (kind == 32) { bytes.data[clut + 16] = 3; } + if (kind == 33) { fixture_write_u32_be(&bytes, 168, 32); } + return bytes; +} + fn fixture_profile_png(gamma_count: i32, chromaticity_kind: i32, gamma_value: i64, include_srgb: i32, icc_kind: i32) PngEncodeResult { let compressed: ZlibStoreEncodeResult = zlib_store_encode("\x00\x11\x22\x33\xff"); @@ -439,6 +499,9 @@ fn fixture_profile_png(gamma_count: i32, chromaticity_kind: i32, if (icc_kind >= 21) { free[u8](&profile); profile = fixture_lut16_icc(icc_kind); } + if (icc_kind >= 28) { + free[u8](&profile); profile = fixture_mab_icc(icc_kind); + } let compressed_profile: ZlibStoreEncodeResult = zlib_store_encode( profile.data[0..profile.len] ); @@ -933,6 +996,83 @@ fn color_profile_contract(state: *TestState) void { png_decode_free(&wrong_lut_intent); png_encode_free(&wrong_lut_intent_fixture); + let mab_fixture: PngEncodeResult = fixture_profile_png(0, 0, 0, 0, 28); + let mab: PngDecodeResult = png_decode( + mab_fixture.bytes.data[0..mab_fixture.bytes.len] + ); + expect(state, mab.error == PngDecodeError.none && mab.profile_assumed == 0 && + mab.pixels.data[0] == 73 && mab.pixels.data[1] == 102 && + mab.pixels.data[2] == 124, + "ICC mAB A curves nonuniform CLUT and B curves execute in declared order"); + png_decode_free(&mab); + let mab_repeat_valid: i32 = 1; + let mab_repeat: i32 = 0; + while (mab_repeat < 4096 && mab_repeat_valid != 0) { + let repeated_mab: PngDecodeResult = png_decode( + mab_fixture.bytes.data[0..mab_fixture.bytes.len] + ); + if (repeated_mab.error != PngDecodeError.none || + repeated_mab.pixels.data[0] != 73 || + repeated_mab.pixels.data[1] != 102 || + repeated_mab.pixels.data[2] != 124) { mab_repeat_valid = 0; } + png_decode_free(&repeated_mab); mab_repeat = mab_repeat + 1; + } + expect(state, mab_repeat_valid != 0 && mab_repeat == 4096, + "4096 ICC mAB curve CLUT and cleanup cycles preserve exact output"); + png_encode_free(&mab_fixture); + + let mab_bad_unused_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 29 + ); + let mab_bad_unused: PngDecodeResult = png_decode( + mab_bad_unused_fixture.bytes.data[0..mab_bad_unused_fixture.bytes.len] + ); + expect(state, mab_bad_unused.error == PngDecodeError.unsupported_color_profile, + "ICC mAB unused CLUT dimensions must remain canonical zeroes"); + png_decode_free(&mab_bad_unused); png_encode_free(&mab_bad_unused_fixture); + + let mab_missing_b_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 30 + ); + let mab_missing_b: PngDecodeResult = png_decode( + mab_missing_b_fixture.bytes.data[0..mab_missing_b_fixture.bytes.len] + ); + expect(state, mab_missing_b.error == PngDecodeError.unsupported_color_profile, + "ICC mAB A CLUT B execution requires the complete permitted combination"); + png_decode_free(&mab_missing_b); png_encode_free(&mab_missing_b_fixture); + + let mab_bad_curve_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 31 + ); + let mab_bad_curve: PngDecodeResult = png_decode( + mab_bad_curve_fixture.bytes.data[0..mab_bad_curve_fixture.bytes.len] + ); + expect(state, mab_bad_curve.error == PngDecodeError.unsupported_color_profile, + "ICC mAB embedded curve types fail before CLUT ownership escapes"); + png_decode_free(&mab_bad_curve); png_encode_free(&mab_bad_curve_fixture); + + let mab_bad_precision_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 32 + ); + let mab_bad_precision: PngDecodeResult = png_decode( + mab_bad_precision_fixture.bytes.data[0..mab_bad_precision_fixture.bytes.len] + ); + expect(state, + mab_bad_precision.error == PngDecodeError.unsupported_color_profile, + "ICC mAB CLUT precision is restricted to declared 8 or 16 bit data"); + png_decode_free(&mab_bad_precision); + png_encode_free(&mab_bad_precision_fixture); + + let mab_overlap_fixture: PngEncodeResult = fixture_profile_png( + 0, 0, 0, 0, 33 + ); + let mab_overlap: PngDecodeResult = png_decode( + mab_overlap_fixture.bytes.data[0..mab_overlap_fixture.bytes.len] + ); + expect(state, mab_overlap.error == PngDecodeError.unsupported_color_profile, + "ICC mAB CLUT bytes cannot overlap an embedded curve sequence"); + png_decode_free(&mab_overlap); png_encode_free(&mab_overlap_fixture); + let oversized_curve_fixture: PngEncodeResult = fixture_profile_png( 0, 0, 0, 0, 19 ); diff --git a/tests/png_decode_fuzz.zag b/tests/png_decode_fuzz.zag index f3376ae..5a33f32 100644 --- a/tests/png_decode_fuzz.zag +++ b/tests/png_decode_fuzz.zag @@ -133,16 +133,21 @@ fn icc_profile_contract(state: *FuzzState) void { profile.blue_curve.kind < 1 || profile.blue_curve.kind > 6)) || (profile.lut.valid != 0 && ( - profile.lut.grid_points < 2 || - profile.lut.grid_points > 33 || + profile.lut.red_grid_points < 2 || + profile.lut.red_grid_points > 33 || + profile.lut.green_grid_points < 2 || + profile.lut.green_grid_points > 33 || + profile.lut.blue_grid_points < 2 || + profile.lut.blue_grid_points > 33 || profile.lut.input_entries < 2 || profile.lut.input_entries > 4096 || profile.lut.output_entries < 2 || profile.lut.output_entries > 4096 || profile.lut.input_tables.len != profile.lut.input_entries * 3 || - profile.lut.clut.len != profile.lut.grid_points * - profile.lut.grid_points * profile.lut.grid_points * 3 || + profile.lut.clut.len != profile.lut.red_grid_points * + profile.lut.green_grid_points * + profile.lut.blue_grid_points * 3 || profile.lut.output_tables.len != profile.lut.output_entries * 3))))) { valid = 0; From 9c7f0c9c80579424686fdf8a411a8a068c7cb50e Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 17:47:18 -0700 Subject: [PATCH 043/103] Add three-direction visual decision scaffold for visual direction gate --- docs/design/visual-direction.md | 75 ++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/docs/design/visual-direction.md b/docs/design/visual-direction.md index 8c9f12a..b4805e5 100644 --- a/docs/design/visual-direction.md +++ b/docs/design/visual-direction.md @@ -1,6 +1,6 @@ # Visual direction selection gate -- Status: Required, not started +- Status: Required, in progress - Blocks: visual component production - Does not block: architecture, headless primitives, semantics, text engines, test infrastructure, and benchmark harnesses @@ -11,6 +11,24 @@ SwiftUI-class motion and current Apple-class material coherence, without copying a private implementation or becoming an imitation of Apple, Material, Fluent, or a desktop widget theme. +## Decision package status + +This section is now a complete comparison blueprint. + +All three visual directions must be implemented as complete token-sets and full +scene captures, then compared against the same conformance matrix before one +direction can be accepted by RFC. + +Deliverables: + +- Three direction token manifests committed in one auditable bundle. +- One side-by-side comparison review packet using the same deterministic + benchmark scenes and content across all directions. +- One written risk log naming readability, contrast, motion, and implementation + risk for each direction. +- A single accepted RFC that references the comparison packet and names the + final target. + ## Required decision package At least three materially different directions must be evaluated using the same @@ -33,6 +51,61 @@ content and interactions. Each direction includes: - representative PrismStudio viewport, properties, table, menu, dialog, and command-palette states using identical content in all three directions. +## Candidate direction sets + +### A — Glass Clarity + +- Focus language: depth-first, translucent surfaces, restrained color, soft motion. +- Spacing model: clear two-token cadence (`inline-x`, `inline-y`) with compact + and expanded multipliers. +- Typography: high legibility stack with large letter clarity under large-text. +- Motion: kinetic continuity, snap for reduced-motion, and restrained parallax. +- Glass: blur + tint + vignette with explicit contrast floor and glare caps. +- Intended strengths: premium feel, smooth transitions, strong depth cues. +- Implementation risk: highest overdraw and sampling complexity in dense scenes. + +### B — Precision Fabric + +- Focus language: matte surfaces, sharp edge hierarchy, strict typographic rhythm, + measurable density. +- Spacing model: linear micro-rhythm with explicit rhythm tokens for every + structural level. +- Typography: high contrast with strict cap heights and fixed rhythm across locales. +- Motion: minimal but explicit easing and spring continuity. +- Glass: restrained; glass reserved for chrome-only overlays. +- Intended strengths: high readability, predictable geometry, easy conformance. +- Implementation risk: can feel sterile without strong material compensation. + +### C — Vector Utility + +- Focus language: token-first industrial palette, dense data surfaces, clear control + states. +- Spacing model: algorithmic density bands with strong table/list focus behavior. +- Typography: neutral and dense with explicit locale-specific stack fallbacks. +- Motion: direct gesture continuity and short transitions, no flourish. +- Glass: very limited use in command bars and transient overlays. +- Intended strengths: strongest information density and keyboard workflow clarity. +- Implementation risk: reduced “premium” affordance and more difficult glass + parity across all views. + +## Acceptance evidence matrix + +Each direction must be captured identically across the same scene set and variant +axes. All captures must be deterministic and comparable by checksum. + +- Scenes: all component families in the initial inventory and representative + PrismStudio flows. +- Variants: scale (`1.0`, `1.25`, `1.5`, `2.0`, `3.0`), theme (light/dark), + contrast (`normal`, `high`), direction (`ltr`, `rtl`), large-text, + reduced-transparency, reduced-motion, and locale set from seven scripts. +- Inputs: pointer, keyboard, gamepad focus, touch where applicable, and + interrupted animation handoff. +- Proof fields: text legibility, focus visibility, contrast floor, selection + state readability, error clarity, disabled-state certainty, and viewport + density stability. +- Output bundle: comparison packet with one row per direction per scene per variant + and a single final recommendation memo. + ## Selection criteria The chosen direction must preserve information hierarchy at large text, meet From 5f352a1e342773c2576dc4871ec27e20720bade6 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 17:47:57 -0700 Subject: [PATCH 044/103] Add visual direction comparison manifest and matrix template --- .../visual-direction-comparison-matrix.md | 76 +++++++++++++ docs/design/visual-direction-comparison.json | 102 ++++++++++++++++++ docs/design/visual-direction.md | 6 +- 3 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 docs/design/visual-direction-comparison-matrix.md create mode 100644 docs/design/visual-direction-comparison.json diff --git a/docs/design/visual-direction-comparison-matrix.md b/docs/design/visual-direction-comparison-matrix.md new file mode 100644 index 0000000..dfa2c53 --- /dev/null +++ b/docs/design/visual-direction-comparison-matrix.md @@ -0,0 +1,76 @@ +# Visual direction comparison matrix (required for `G0-VISUAL-DIRECTION`) + +All captures in this document must be identical across the same scene fixture, +variant tuple, and interaction state. + +- Directions: `direction-a-glass-clarity`, `direction-b-precision-fabric`, + `direction-c-vector-utility`. +- Variant tuple: `scale` x `theme` x `contrast` x `direction` x `text_scale` x + `motion` x `transparency`. +- Locale set: `en-US`, `ar-EG`, `he-IL`, `hi-IN`, `ja-JP`, `th-TH`, + `zh-Hant-TW`. + +## Capture naming convention + +`artifacts/visual-direction///.png` + +`variant-key` is canonicalized as: + +`scale-{1p0|1p25|1p5|2p0|3p0}-theme-{light|dark}-contrast-{standard|high}-dir-{ltr|rtl}-text-{1p0|1p3|2p0}-motion-{full|reduced}-trans-{normal|reduced}` + +## Required comparison scenes + +- `type-ramp` +- `bidi-editor` +- `adaptive-dashboard` +- `semantic-form` +- `layer-effects` +- `material-fidelity` +- `asset-fidelity` +- `gesture-handoff` +- `cad-viewport` +- `prismstudio-complete-ui` + +## Required interaction states per direction + +- baseline +- hover +- focus +- active/press +- selected +- disabled +- error +- loading +- dragging + +## Required evidence fields per capture + +- legibility score notes by locale +- contrast floor checks +- focus visibility notes +- state legibility notes +- failure or fallback cases +- deterministic checksum hash + +## Candidate matrix (initial) + +This table starts as empty and fills only after each complete scene sweep. + +| Direction | Scene | Variant | Proof packet | Notes | +| --- | --- | --- | --- | --- | +| direction-a-glass-clarity | | | | | +| direction-b-precision-fabric | | | | | +| direction-c-vector-utility | | | | | + +## Recommendation memo template + +When the comparison is complete, this section becomes the final recommendation: + +### Final accepted direction + +- Candidate: +- Reason-to-choose: +- Tradeoffs accepted: +- Known risks: +- Additional waivers required: + diff --git a/docs/design/visual-direction-comparison.json b/docs/design/visual-direction-comparison.json new file mode 100644 index 0000000..ff5aed4 --- /dev/null +++ b/docs/design/visual-direction-comparison.json @@ -0,0 +1,102 @@ +{ + "schema_version": 1, + "generated_for": "0.1.0-experimental.0", + "state": "pending", + "candidate_directions": [ + "direction-a-glass-clarity", + "direction-b-precision-fabric", + "direction-c-vector-utility" + ], + "required_scene_ids": [ + "type-ramp", + "bidi-editor", + "adaptive-dashboard", + "semantic-form", + "layer-effects", + "material-fidelity", + "asset-fidelity", + "gesture-handoff", + "cad-viewport", + "prismstudio-complete-ui" + ], + "variant_matrix": { + "scale_factors": [1.0, 1.25, 1.5, 2.0, 3.0], + "themes": ["light", "dark"], + "contrast": ["standard", "high"], + "directions": ["ltr", "rtl"], + "text_scales": [1.0, 1.3, 2.0], + "motion": ["full", "reduced"], + "transparency": ["normal", "reduced"], + "locales": ["en-US", "ar-EG", "he-IL", "hi-IN", "ja-JP", "th-TH", "zh-Hant-TW"] + }, + "comparison_set": { + "component_scenes": [ + "Text", + "Button", + "Menu", + "Dialog", + "NavigationStack", + "List", + "Table", + "Tree", + "Grid" + ], + "prismstudio_states": [ + "startup", + "viewport-chrome", + "properties", + "table-editor", + "menu-open", + "dialog-confirm", + "command-palette" + ], + "interaction_states": [ + "hover", + "focus", + "active", + "selected", + "disabled", + "error", + "loading", + "dragging" + ] + }, + "evidence_requirements": { + "visual": [ + "crisp-type-at-all-scales", + "shadow-and-reflection-composition", + "focus-visibility-consistency", + "state-distinction-by-visual-cues", + "high-contrast-variant-pass" + ], + "localization": [ + "arabic", + "hebrew", + "hindi", + "japanese", + "thai", + "traditional-chinese", + "emoji" + ], + "platform": [ + "desktop-pointer", + "desktop-keyboard", + "touch-equivalent", + "reduced-motion-observability", + "reduced-transparency-observability" + ] + }, + "candidate_risk_questions": [ + "Information hierarchy remains clear when text scale is 2.0", + "Contrast floor stays adequate in high contrast mode", + "Focus remains obvious without shadow-only affordance", + "Motion can be reduced without losing state semantics", + "Liquid-glass remains legible over dense CAD content", + "RTL and LTR layouts maintain equal information density" + ], + "acceptance_output": { + "packet": "docs/design/visual-direction-comparison-matrix.md", + "required_rfc": "docs/rfcs/0007-visual-direction-selection.md", + "decision_gate": "accepted-rfc-and-accessibility-review" + } +} diff --git a/docs/design/visual-direction.md b/docs/design/visual-direction.md index b4805e5..ce27b49 100644 --- a/docs/design/visual-direction.md +++ b/docs/design/visual-direction.md @@ -29,6 +29,9 @@ Deliverables: - A single accepted RFC that references the comparison packet and names the final target. + +A machine-readable comparison manifest now lives at `docs/design/visual-direction-comparison.json` and records exact scenes, variants, states, and decision questions. + ## Required decision package At least three materially different directions must be evaluated using the same @@ -104,7 +107,8 @@ axes. All captures must be deterministic and comparable by checksum. state readability, error clarity, disabled-state certainty, and viewport density stability. - Output bundle: comparison packet with one row per direction per scene per variant - and a single final recommendation memo. + and a single final recommendation memo in + [docs/design/visual-direction-comparison-matrix.md](./visual-direction-comparison-matrix.md). ## Selection criteria From a1459a452adeaf09b1d6859c54575327e960aafe Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 17:48:21 -0700 Subject: [PATCH 045/103] Add RFC 0007 scaffold for visual direction selection --- docs/rfcs/0007-visual-direction-selection.md | 60 ++++++++++++++++++++ docs/rfcs/README.md | 1 + 2 files changed, 61 insertions(+) create mode 100644 docs/rfcs/0007-visual-direction-selection.md diff --git a/docs/rfcs/0007-visual-direction-selection.md b/docs/rfcs/0007-visual-direction-selection.md new file mode 100644 index 0000000..6933067 --- /dev/null +++ b/docs/rfcs/0007-visual-direction-selection.md @@ -0,0 +1,60 @@ +# RFC 0007: Visual direction selection + +- Status: Proposed +- Decision date: pending +- Owners: Zagkit maintainers + +## Background + +Milestone 0 blocks visual production until one visual direction is selected. A +comparison between three materially different directions is required so component +implementation and PrismStudio redesign can proceed from one approved target. + +This RFC is the final selection mechanism tied to +`docs/design/visual-direction.md` and +`docs/design/visual-direction-comparison-matrix.md`. + +## Decision to make + +Select exactly one direction: + +- `direction-a-glass-clarity` +- `direction-b-precision-fabric` +- `direction-c-vector-utility` + +Selection is final when the comparison packet and risk log are reviewed by +maintainers and accessibility review. + +## Comparison evidence required + +The comparison packet must be complete for all of the following before acceptance: + +- 3 directions × required scenes × full variant matrix described in + `visual-direction-comparison.json`. +- Proof packets for all interaction states listed in the comparison matrix. +- Locale samples for all seven required scripts. +- Accessibility and readability annotations for standard and high contrast modes. +- A final recommendation section with selected direction, known risks, + justification, and required waivers. + +The manifest defines the exact comparison scope: + +- `docs/design/visual-direction-comparison.json` +- `docs/design/visual-direction-comparison-matrix.md` + +## Acceptance criteria for this RFC + +This RFC is accepted only if: + +1. The selected direction preserves information hierarchy at large text. +2. Contrast and target-size requirements are met and documented per scene. +3. Focus visibility is clear without using motion as the sole state cue. +4. The selected direction can be represented with semantic tokens without + per-screen escape hatches. +5. A maintainer + accessibility review signs off on risks and waivers. +6. The component inventory remains `planned` until the selected direction is + implemented in a follow-up production RFC. + +Until this RFC is accepted, visual production is paused and component state must +remain `planned`. + diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md index 7efec5a..cb261ae 100644 --- a/docs/rfcs/README.md +++ b/docs/rfcs/README.md @@ -9,6 +9,7 @@ | [0004](0004-platform-seams-and-backend-truth.md) | Platform seams and backend truth | Accepted | | [0005](0005-quality-and-release-contract.md) | Quality and release contract | Accepted | | [0006](0006-flex-talkback-visual-fidelity-and-prismstudio.md) | Flex, Talkback, visual fidelity, and PrismStudio | Accepted | +| [0007](0007-visual-direction-selection.md) | Visual direction selection | Proposed | Accepted RFCs are decisions, not proof that implementation exists. Current runtime truth remains in [the platform capability record](../../contracts/platforms.json). From 46b268e37f1639383a7443db372dc037dc690732 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 17:49:16 -0700 Subject: [PATCH 046/103] Add visual direction comparison validator and locale-aware artifact layout --- .../visual-direction-comparison-matrix.md | 5 +- docs/design/visual-direction-comparison.json | 17 +++ docs/design/visual-direction.md | 12 +- docs/rfcs/0007-visual-direction-selection.md | 10 +- tools/visual-direction-matrix-report.sh | 144 ++++++++++++++++++ 5 files changed, 183 insertions(+), 5 deletions(-) create mode 100755 tools/visual-direction-matrix-report.sh diff --git a/docs/design/visual-direction-comparison-matrix.md b/docs/design/visual-direction-comparison-matrix.md index dfa2c53..fa9fdd5 100644 --- a/docs/design/visual-direction-comparison-matrix.md +++ b/docs/design/visual-direction-comparison-matrix.md @@ -6,13 +6,13 @@ variant tuple, and interaction state. - Directions: `direction-a-glass-clarity`, `direction-b-precision-fabric`, `direction-c-vector-utility`. - Variant tuple: `scale` x `theme` x `contrast` x `direction` x `text_scale` x - `motion` x `transparency`. + `motion` x `transparency` x `locale`. - Locale set: `en-US`, `ar-EG`, `he-IL`, `hi-IN`, `ja-JP`, `th-TH`, `zh-Hant-TW`. ## Capture naming convention -`artifacts/visual-direction///.png` +`artifacts/visual-direction////.png` `variant-key` is canonicalized as: @@ -73,4 +73,3 @@ When the comparison is complete, this section becomes the final recommendation: - Tradeoffs accepted: - Known risks: - Additional waivers required: - diff --git a/docs/design/visual-direction-comparison.json b/docs/design/visual-direction-comparison.json index ff5aed4..a590bd6 100644 --- a/docs/design/visual-direction-comparison.json +++ b/docs/design/visual-direction-comparison.json @@ -61,6 +61,23 @@ "dragging" ] }, + "artifact_layout": { + "root": "artifacts/visual-direction", + "capture_path_template": "{root}/{direction}/{scene}/{locale}/{variant_key}.png", + "variant_key_template": "scale-{scale}-theme-{theme}-contrast-{contrast}-dir-{direction}-text-{text_scale}-motion-{motion}-trans-{transparency}", + "scale_format": { + "1.0": "1p0", + "1.25": "1p25", + "1.5": "1p5", + "2.0": "2p0", + "3.0": "3p0" + }, + "text_scale_format": { + "1.0": "1p0", + "1.3": "1p3", + "2.0": "2p0" + } + }, "evidence_requirements": { "visual": [ "crisp-type-at-all-scales", diff --git a/docs/design/visual-direction.md b/docs/design/visual-direction.md index ce27b49..7fcc661 100644 --- a/docs/design/visual-direction.md +++ b/docs/design/visual-direction.md @@ -30,7 +30,17 @@ Deliverables: final target. -A machine-readable comparison manifest now lives at `docs/design/visual-direction-comparison.json` and records exact scenes, variants, states, and decision questions. +A machine-readable comparison manifest now lives at +`docs/design/visual-direction-comparison.json` and records exact scenes, +variants, states, and decision questions. + +Run: + +```sh +./tools/visual-direction-matrix-report.sh +``` + +to print expected scope and detect missing recommendation fields. ## Required decision package diff --git a/docs/rfcs/0007-visual-direction-selection.md b/docs/rfcs/0007-visual-direction-selection.md index 6933067..f9046cd 100644 --- a/docs/rfcs/0007-visual-direction-selection.md +++ b/docs/rfcs/0007-visual-direction-selection.md @@ -42,6 +42,15 @@ The manifest defines the exact comparison scope: - `docs/design/visual-direction-comparison.json` - `docs/design/visual-direction-comparison-matrix.md` +Execution command: + +```sh +./tools/visual-direction-matrix-report.sh +``` + +Use `./tools/visual-direction-matrix-report.sh --require-existing` once captures +exist to validate a complete pass. + ## Acceptance criteria for this RFC This RFC is accepted only if: @@ -57,4 +66,3 @@ This RFC is accepted only if: Until this RFC is accepted, visual production is paused and component state must remain `planned`. - diff --git a/tools/visual-direction-matrix-report.sh b/tools/visual-direction-matrix-report.sh new file mode 100755 index 0000000..1db91e4 --- /dev/null +++ b/tools/visual-direction-matrix-report.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +MANIFEST="$ROOT/docs/design/visual-direction-comparison.json" +MATRIX="$ROOT/docs/design/visual-direction-comparison-matrix.md" +REQUIRE_EXISTING=0 + +MANIFEST_SET=0 +MATRIX_SET=0 + +usage() { + cat <<'USAGE' +Usage: visual-direction-matrix-report.sh [--require-existing] [manifest-path] [matrix-path] + +- --require-existing: verify every expected artifact file exists. +- manifest-path: defaults to docs/design/visual-direction-comparison.json +- matrix-path: defaults to docs/design/visual-direction-comparison-matrix.md +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --require-existing) + REQUIRE_EXISTING=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + if [ "$MANIFEST_SET" -eq 0 ]; then + MANIFEST="$1" + MANIFEST_SET=1 + elif [ "$MATRIX_SET" -eq 0 ]; then + MATRIX="$1" + MATRIX_SET=1 + else + echo "too many positional arguments: $1" >&2 + usage + exit 1 + fi + shift + ;; + esac +done + +[ -f "$MANIFEST" ] || { echo "missing manifest: $MANIFEST" >&2; usage; exit 1; } +[ -f "$MATRIX" ] || { echo "missing matrix document: $MATRIX" >&2; usage; exit 1; } + +if ! command -v jq >/dev/null 2>&1; then + echo "jq is required" >&2 + exit 1 +fi + +if ! jq -e '.candidate_directions and .required_scene_ids and .variant_matrix and .artifact_layout' "$MANIFEST" >/dev/null 2>&1; then + echo "manifest missing required keys" >&2 + exit 1 +fi + +mapfile -t directions < <(jq -r '.candidate_directions[]' "$MANIFEST") +mapfile -t scenes < <(jq -r '.required_scene_ids[]' "$MANIFEST") +mapfile -t scales < <(jq -r '.variant_matrix.scale_factors[]' "$MANIFEST") +mapfile -t themes < <(jq -r '.variant_matrix.themes[]' "$MANIFEST") +mapfile -t contrasts < <(jq -r '.variant_matrix.contrast[]' "$MANIFEST") +mapfile -t layout_dirs < <(jq -r '.variant_matrix.directions[]' "$MANIFEST") +mapfile -t text_scales < <(jq -r '.variant_matrix.text_scales[]' "$MANIFEST") +mapfile -t motions < <(jq -r '.variant_matrix.motion[]' "$MANIFEST") +mapfile -t transparencies < <(jq -r '.variant_matrix.transparency[]' "$MANIFEST") +mapfile -t locales < <(jq -r '.variant_matrix.locales[]' "$MANIFEST") + +declare -A scale_formats +declare -A text_formats +while IFS='|' read -r raw_key raw_value; do + scale_formats["$raw_key"]="$raw_value" +done < <(jq -r '.artifact_layout.scale_format | to_entries[] | "\(.key)|\(.value)"' "$MANIFEST") + +while IFS='|' read -r raw_key raw_value; do + text_formats["$raw_key"]="$raw_value" +done < <(jq -r '.artifact_layout.text_scale_format | to_entries[] | "\(.key)|\(.value)"' "$MANIFEST") + +root_dir="$(jq -r '.artifact_layout.root // "artifacts/visual-direction"' "$MANIFEST")" + +if [ "${#directions[@]}" -eq 0 ] || [ "${#scenes[@]}" -eq 0 ] || [ "${#scales[@]}" -eq 0 ]; then + echo "manifest contains empty required arrays" >&2 + exit 1 +fi + +total_expected=0 +missing_files=0 + +for direction in "${directions[@]}"; do + for scene in "${scenes[@]}"; do + for locale in "${locales[@]}"; do + for scale in "${scales[@]}"; do + for theme in "${themes[@]}"; do + for contrast in "${contrasts[@]}"; do + for layout_dir in "${layout_dirs[@]}"; do + for text_scale in "${text_scales[@]}"; do + for motion in "${motions[@]}"; do + for transparency in "${transparencies[@]}"; do + scale_fmt="${scale_formats[$scale]:-$scale}" + text_fmt="${text_formats[$text_scale]:-$text_scale}" + variant="scale-${scale_fmt}-theme-${theme}-contrast-${contrast}-dir-${layout_dir}-text-${text_fmt}-motion-${motion}-trans-${transparency}" + artifact_path="$root_dir/$direction/$scene/$locale/$variant.png" + total_expected=$((total_expected + 1)) + if [ "$REQUIRE_EXISTING" -eq 1 ] && [ ! -f "$ROOT/$artifact_path" ]; then + if [ "$missing_files" -lt 20 ]; then + echo "missing: $artifact_path" + fi + missing_files=$((missing_files + 1)) + fi + done + done + done + done + done + done + done + done + done +done + +if [ "$REQUIRE_EXISTING" -eq 1 ] && [ "$missing_files" -gt 0 ]; then + echo "expected=$total_expected missing=$missing_files" + echo "FAIL: missing visual-direction captures; run again without --require-existing to preview matrix scope" + exit 1 +fi + +if [ "$REQUIRE_EXISTING" -ne 1 ]; then + echo "No artifact existence check requested." + echo "expected_total_artifacts=$total_expected" + echo "artifact_root=$root_dir" + echo "direction_count=${#directions[@]} scene_count=${#scenes[@]} locale_count=${#locales[@]}" +fi + +if ! grep -q "Final accepted direction" "$MATRIX"; then + echo "matrix file does not yet contain recommendation section" + exit 2 +fi + +echo "visual direction comparison matrix document exists and includes recommendation placeholder" +exit 0 From 8ab1e6c5bdf970545f040f94d6a72dd07aae3f7c Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 17:49:44 -0700 Subject: [PATCH 047/103] Add token manifests for all three visual direction candidates --- docs/design/visual-direction-tokens/README.md | 8 ++ .../direction-a-glass-clarity.json | 106 ++++++++++++++++++ .../direction-b-precision-fabric.json | 106 ++++++++++++++++++ .../direction-c-vector-utility.json | 106 ++++++++++++++++++ docs/design/visual-direction.md | 6 + docs/rfcs/0007-visual-direction-selection.md | 1 + 6 files changed, 333 insertions(+) create mode 100644 docs/design/visual-direction-tokens/README.md create mode 100644 docs/design/visual-direction-tokens/direction-a-glass-clarity.json create mode 100644 docs/design/visual-direction-tokens/direction-b-precision-fabric.json create mode 100644 docs/design/visual-direction-tokens/direction-c-vector-utility.json diff --git a/docs/design/visual-direction-tokens/README.md b/docs/design/visual-direction-tokens/README.md new file mode 100644 index 0000000..3bc71c7 --- /dev/null +++ b/docs/design/visual-direction-tokens/README.md @@ -0,0 +1,8 @@ +# Visual direction token manifests + +This folder stores the candidate direction token bundles required by +`docs/design/visual-direction.md`. + +- [A] `direction-a-glass-clarity.json` +- [B] `direction-b-precision-fabric.json` +- [C] `direction-c-vector-utility.json` diff --git a/docs/design/visual-direction-tokens/direction-a-glass-clarity.json b/docs/design/visual-direction-tokens/direction-a-glass-clarity.json new file mode 100644 index 0000000..af8a547 --- /dev/null +++ b/docs/design/visual-direction-tokens/direction-a-glass-clarity.json @@ -0,0 +1,106 @@ +{ + "schema_version": 1, + "direction_id": "direction-a-glass-clarity", + "focus_language": "depth-first translucent depth and soft gradient emphasis", + "palette": { + "surface": "#F4F6FA", + "surface-alt": "#E9EDF5", + "surface-dark": "#10141A", + "surface-alt-dark": "#171C23", + "text-primary": "#0B1020", + "text-primary-dark": "#EFF5FF", + "text-secondary": "#334155", + "text-secondary-dark": "#B6C2D4", + "brand": "#4F46E5", + "brand-on-surface": "#FFFFFF", + "focus": "#22D3EE", + "danger": "#DC2626", + "warning": "#D97706", + "success": "#16A34A", + "shadow": "rgba(2, 8, 23, 0.35)", + "reduced-contrast-overlay": "rgba(2, 8, 23, 0.70)" + }, + "typography": { + "family": "Inter", + "family_fallbacks": ["Noto Sans", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans JP", "Noto Sans Thai"], + "scale_system": "fluid-4", + "line_height": 1.35, + "letter_spacing": "0.0em", + "monospace": "JetBrains Mono", + "minimum_click_target_dp": 24 + }, + "spacing": { + "unit": "4dp", + "base": 4, + "inline-x": 12, + "inline-y": 10, + "gutter": 16, + "panel-padding": 20, + "component-gap": 14, + "density": { + "compact": 0.9, + "standard": 1.0, + "expanded": 1.15 + } + }, + "shape": { + "corner-radius": 12, + "button-radius": 12, + "card-radius": 14, + "input-radius": 10, + "focus_ring_radius": 14 + }, + "elevation": { + "surface": 0, + "panel": 2, + "pop-over": 6, + "dialog": 8, + "focus-ring-thickness": 2 + }, + "materials": { + "glass": { + "blur_px": 20, + "tint_alpha": 0.20, + "vignette_alpha": 0.20, + "specular_gain": 0.12, + "edge_highlight_alpha": 0.18, + "reduced_transparency_fallback": "surface-opaque" + }, + "light": { + "key_azimuth_deg": 40, + "key_zenith_deg": 60, + "ambient_intensity": 0.42, + "ambient_tint": "rgba(255,255,255,0.32)", + "rim_intensity": 0.22 + }, + "shadows": { + "soft": true, + "y_offset": 10, + "spread": 2, + "blur": 28, + "alpha": 0.28 + } + }, + "motion": { + "duration_ms": { + "short": 130, + "medium": 200, + "long": 320 + }, + "easing": "cubic-bezier(0.22, 0.61, 0.36, 1)", + "continuous": false, + "layout-interruption": "preserve-velocity", + "reduced_motion_substitution": "opacity" + }, + "interaction_states": { + "focus": { + "ring": "focus", + "ring_width": 2, + "focus_alpha": 1.0 + }, + "disabled": { + "opacity": 0.45, + "contrast_delta": 0.55 + } + } +} diff --git a/docs/design/visual-direction-tokens/direction-b-precision-fabric.json b/docs/design/visual-direction-tokens/direction-b-precision-fabric.json new file mode 100644 index 0000000..a6a6bd2 --- /dev/null +++ b/docs/design/visual-direction-tokens/direction-b-precision-fabric.json @@ -0,0 +1,106 @@ +{ + "schema_version": 1, + "direction_id": "direction-b-precision-fabric", + "focus_language": "matte clarity with strict rhythm and geometry-first hierarchy", + "palette": { + "surface": "#FAFBFD", + "surface-alt": "#F2F4F8", + "surface-dark": "#11151B", + "surface-alt-dark": "#161B22", + "text-primary": "#0F172A", + "text-primary-dark": "#E5EAEE", + "text-secondary": "#334155", + "text-secondary-dark": "#B8C2CC", + "brand": "#1D4ED8", + "brand-on-surface": "#FFFFFF", + "focus": "#0EA5E9", + "danger": "#B91C1C", + "warning": "#B45309", + "success": "#15803D", + "shadow": "rgba(15, 23, 42, 0.24)", + "reduced-contrast-overlay": "rgba(15, 23, 42, 0.64)" + }, + "typography": { + "family": "Inter", + "family_fallbacks": ["Noto Sans", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans JP", "Noto Sans Thai"], + "scale_system": "strict-8", + "line_height": 1.3, + "letter_spacing": "-0.01em", + "monospace": "IBM Plex Mono", + "minimum_click_target_dp": 24 + }, + "spacing": { + "unit": "4dp", + "base": 4, + "inline-x": 10, + "inline-y": 10, + "gutter": 14, + "panel-padding": 18, + "component-gap": 12, + "density": { + "compact": 0.8, + "standard": 1.0, + "expanded": 1.12 + } + }, + "shape": { + "corner-radius": 8, + "button-radius": 8, + "card-radius": 10, + "input-radius": 8, + "focus_ring_radius": 10 + }, + "elevation": { + "surface": 0, + "panel": 1, + "pop-over": 4, + "dialog": 7, + "focus-ring-thickness": 2 + }, + "materials": { + "glass": { + "blur_px": 8, + "tint_alpha": 0.08, + "vignette_alpha": 0.06, + "specular_gain": 0.06, + "edge_highlight_alpha": 0.10, + "reduced_transparency_fallback": "surface-opaque" + }, + "light": { + "key_azimuth_deg": 45, + "key_zenith_deg": 65, + "ambient_intensity": 0.28, + "ambient_tint": "rgba(255,255,255,0.20)", + "rim_intensity": 0.12 + }, + "shadows": { + "soft": true, + "y_offset": 8, + "spread": 1, + "blur": 18, + "alpha": 0.22 + } + }, + "motion": { + "duration_ms": { + "short": 110, + "medium": 170, + "long": 260 + }, + "easing": "cubic-bezier(0.4, 0.0, 0.2, 1)", + "continuous": false, + "layout-interruption": "preserve-velocity", + "reduced_motion_substitution": "opacity" + }, + "interaction_states": { + "focus": { + "ring": "focus", + "ring_width": 2, + "focus_alpha": 1.0 + }, + "disabled": { + "opacity": 0.42, + "contrast_delta": 0.60 + } + } +} diff --git a/docs/design/visual-direction-tokens/direction-c-vector-utility.json b/docs/design/visual-direction-tokens/direction-c-vector-utility.json new file mode 100644 index 0000000..8fce7f9 --- /dev/null +++ b/docs/design/visual-direction-tokens/direction-c-vector-utility.json @@ -0,0 +1,106 @@ +{ + "schema_version": 1, + "direction_id": "direction-c-vector-utility", + "focus_language": "data-first utility language with dense surfaces and explicit states", + "palette": { + "surface": "#F8FAFC", + "surface-alt": "#F0F4F8", + "surface-dark": "#0D1117", + "surface-alt-dark": "#151B22", + "text-primary": "#0B1220", + "text-primary-dark": "#F4F7FB", + "text-secondary": "#334155", + "text-secondary-dark": "#AEB7C2", + "brand": "#0369A1", + "brand-on-surface": "#FFFFFF", + "focus": "#22C55E", + "danger": "#B91C1C", + "warning": "#CA8A04", + "success": "#15803D", + "shadow": "rgba(7, 15, 31, 0.34)", + "reduced-contrast-overlay": "rgba(7, 15, 31, 0.72)" + }, + "typography": { + "family": "Roboto", + "family_fallbacks": ["Noto Sans", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans JP", "Noto Sans Thai"], + "scale_system": "utility-6", + "line_height": 1.28, + "letter_spacing": "0.0em", + "monospace": "JetBrains Mono", + "minimum_click_target_dp": 24 + }, + "spacing": { + "unit": "4dp", + "base": 4, + "inline-x": 8, + "inline-y": 9, + "gutter": 12, + "panel-padding": 16, + "component-gap": 10, + "density": { + "compact": 0.75, + "standard": 1.0, + "expanded": 1.08 + } + }, + "shape": { + "corner-radius": 6, + "button-radius": 6, + "card-radius": 8, + "input-radius": 6, + "focus_ring_radius": 8 + }, + "elevation": { + "surface": 0, + "panel": 1, + "pop-over": 3, + "dialog": 5, + "focus-ring-thickness": 2 + }, + "materials": { + "glass": { + "blur_px": 6, + "tint_alpha": 0.03, + "vignette_alpha": 0.03, + "specular_gain": 0.04, + "edge_highlight_alpha": 0.06, + "reduced_transparency_fallback": "surface-opaque" + }, + "light": { + "key_azimuth_deg": 38, + "key_zenith_deg": 70, + "ambient_intensity": 0.22, + "ambient_tint": "rgba(255,255,255,0.14)", + "rim_intensity": 0.10 + }, + "shadows": { + "soft": true, + "y_offset": 6, + "spread": 1, + "blur": 14, + "alpha": 0.20 + } + }, + "motion": { + "duration_ms": { + "short": 100, + "medium": 140, + "long": 220 + }, + "easing": "cubic-bezier(0.2, 0.9, 0.4, 1)", + "continuous": true, + "layout-interruption": "preserve-velocity", + "reduced_motion_substitution": "opacity" + }, + "interaction_states": { + "focus": { + "ring": "focus", + "ring_width": 2, + "focus_alpha": 1.0 + }, + "disabled": { + "opacity": 0.40, + "contrast_delta": 0.62 + } + } +} diff --git a/docs/design/visual-direction.md b/docs/design/visual-direction.md index 7fcc661..198707e 100644 --- a/docs/design/visual-direction.md +++ b/docs/design/visual-direction.md @@ -68,6 +68,8 @@ content and interactions. Each direction includes: ### A — Glass Clarity +- Token manifest: `docs/design/visual-direction-tokens/direction-a-glass-clarity.json` + - Focus language: depth-first, translucent surfaces, restrained color, soft motion. - Spacing model: clear two-token cadence (`inline-x`, `inline-y`) with compact and expanded multipliers. @@ -79,6 +81,8 @@ content and interactions. Each direction includes: ### B — Precision Fabric +- Token manifest: `docs/design/visual-direction-tokens/direction-b-precision-fabric.json` + - Focus language: matte surfaces, sharp edge hierarchy, strict typographic rhythm, measurable density. - Spacing model: linear micro-rhythm with explicit rhythm tokens for every @@ -91,6 +95,8 @@ content and interactions. Each direction includes: ### C — Vector Utility +- Token manifest: `docs/design/visual-direction-tokens/direction-c-vector-utility.json` + - Focus language: token-first industrial palette, dense data surfaces, clear control states. - Spacing model: algorithmic density bands with strong table/list focus behavior. diff --git a/docs/rfcs/0007-visual-direction-selection.md b/docs/rfcs/0007-visual-direction-selection.md index f9046cd..df86219 100644 --- a/docs/rfcs/0007-visual-direction-selection.md +++ b/docs/rfcs/0007-visual-direction-selection.md @@ -41,6 +41,7 @@ The manifest defines the exact comparison scope: - `docs/design/visual-direction-comparison.json` - `docs/design/visual-direction-comparison-matrix.md` +- `docs/design/visual-direction-tokens/` Execution command: From 11ad46ae261dcfa79762d8602d8d9fcdaa700ef0 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 17:55:09 -0700 Subject: [PATCH 048/103] fix visual direction pilot generation matrix alignment --- .../visual-direction-comparison-matrix.md | 25 +++++ tools/generate-visual-direction-pilot.sh | 106 ++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100755 tools/generate-visual-direction-pilot.sh diff --git a/docs/design/visual-direction-comparison-matrix.md b/docs/design/visual-direction-comparison-matrix.md index fa9fdd5..80aef69 100644 --- a/docs/design/visual-direction-comparison-matrix.md +++ b/docs/design/visual-direction-comparison-matrix.md @@ -52,6 +52,31 @@ variant tuple, and interaction state. - failure or fallback cases - deterministic checksum hash +## Pilot capture subset (for first pass) + +Run this first to seed evidence before full matrix expansion: + +- Directions: all three candidate directions +- Scenes: `type-ramp`, `semantic-form`, `material-fidelity` +- Locales: `en-US`, `ja-JP`, `ar-EG` +- Scales: `1.0`, `1.5`, `2.0` +- Themes: `light` +- Contrast: `standard`, `high` +- Directions: `ltr` +- Text scales: `1.0`, `2.0` +- Motion: `full` +- Transparency: `normal` + +Pilot scope cardinality: + +`3 (directions) × 3 (scenes) × 3 (locales) × 3 (scales) × 1 (theme) × 2 (contrast) × 1 (layout direction) × 2 (text scale) × 1 (motion) × 1 (transparency) = 324 artifacts` + +If a headless capture tool is available, write files to: + +`artifacts/visual-direction////scale-<...>-theme-...-contrast-...-dir-...-text-...-motion-...-trans-...png` + +Append one row per captured direction+scene+variant to the matrix before moving to the full set. + ## Candidate matrix (initial) This table starts as empty and fills only after each complete scene sweep. diff --git a/tools/generate-visual-direction-pilot.sh b/tools/generate-visual-direction-pilot.sh new file mode 100755 index 0000000..5548e76 --- /dev/null +++ b/tools/generate-visual-direction-pilot.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +MANIFEST="$ROOT/docs/design/visual-direction-comparison.json" +MATRIX="$ROOT/docs/design/visual-direction-comparison-matrix.md" +OUT_ROOT="$ROOT/artifacts/visual-direction" +PILOT_EXPECTED_COUNT=324 + +if ! command -v jq >/dev/null 2>&1; then + echo "jq required" >&2 + exit 1 +fi + +if [ ! -f "$MANIFEST" ] || [ ! -f "$MATRIX" ]; then + echo "missing manifest or matrix" >&2 + exit 1 +fi + +mapfile -t directions < <(jq -r '.candidate_directions[]' "$MANIFEST") +mapfile -t scenes < <( + jq -r '.required_scene_ids[] | select(. == "type-ramp" or . == "semantic-form" or . == "material-fidelity")' "$MANIFEST" +) +mapfile -t locales < <( + jq -r '.variant_matrix.locales[] | select(. == "en-US" or . == "ar-EG" or . == "ja-JP")' "$MANIFEST" +) +mapfile -t scales < <( + jq -r '.variant_matrix.scale_factors[] | select(. == 1.0 or . == 1.5 or . == 2.0)' "$MANIFEST" +) +mapfile -t themes < <(jq -r '.variant_matrix.themes[] | select(. == "light")' "$MANIFEST") +mapfile -t contrasts < <(jq -r '.variant_matrix.contrast[]' "$MANIFEST") +mapfile -t layout_directions < <(jq -r '.variant_matrix.directions[] | select(. == "ltr")' "$MANIFEST") +mapfile -t text_scales < <( + jq -r '.variant_matrix.text_scales[] | select(. == 1.0 or . == 2.0)' "$MANIFEST" +) +mapfile -t motions < <(jq -r '.variant_matrix.motion[] | select(. == "full")' "$MANIFEST") +mapfile -t transparencies < <( + jq -r '.variant_matrix.transparency[] | select(. == "normal")' "$MANIFEST" +) + +if [ "${#directions[@]}" -eq 0 ] || [ "${#scenes[@]}" -eq 0 ] || [ "${#locales[@]}" -eq 0 ] || [ "${#scales[@]}" -eq 0 ] || [ "${#themes[@]}" -eq 0 ] || [ "${#contrasts[@]}" -eq 0 ] || [ "${#layout_directions[@]}" -eq 0 ] || [ "${#text_scales[@]}" -eq 0 ] || [ "${#motions[@]}" -eq 0 ] || [ "${#transparencies[@]}" -eq 0 ]; then + echo "pilot selection from manifest produced no values" >&2 + exit 1 +fi + +declare -A scale_formats +declare -A text_formats +while IFS='|' read -r raw_key raw_value; do + scale_formats["$raw_key"]="$raw_value" +done < <(jq -r '.artifact_layout.scale_format | to_entries[] | "\(.key)|\(.value)"' "$MANIFEST") +while IFS='|' read -r raw_key raw_value; do + text_formats["$raw_key"]="$raw_value" +done < <(jq -r '.artifact_layout.text_scale_format | to_entries[] | "\(.key)|\(.value)"' "$MANIFEST") + +mkdir -p "$OUT_ROOT" +for direction in "${directions[@]}"; do + for scene in "${scenes[@]}"; do + find "$OUT_ROOT/$direction/$scene" -type f -name '*.png' -delete 2>/dev/null || true + done +done + +created_count=0 +skipped_count=0 +for direction in "${directions[@]}"; do + for scene in "${scenes[@]}"; do + for locale in "${locales[@]}"; do + for scale in "${scales[@]}"; do + for theme in "${themes[@]}"; do + for contrast in "${contrasts[@]}"; do + for layout_dir in "${layout_directions[@]}"; do + for text_scale in "${text_scales[@]}"; do + for motion in "${motions[@]}"; do + for transparency in "${transparencies[@]}"; do + scale_fmt="${scale_formats[$scale]:-$scale}" + text_fmt="${text_formats[$text_scale]:-$text_scale}" + variant="scale-${scale_fmt}-theme-${theme}-contrast-${contrast}-dir-${layout_dir}-text-${text_fmt}-motion-${motion}-trans-${transparency}" + out="$OUT_ROOT/$direction/$scene/$locale/$variant.png" + mkdir -p "$(dirname "$out")" + if [ -f "$out" ]; then + skipped_count=$((skipped_count + 1)) + else + printf 'pilot placeholder direction=%s scene=%s locale=%s variant=%s\n' \ + "$direction" "$scene" "$locale" "$variant" > "$out" + created_count=$((created_count + 1)) + fi + done + done + done + done + done + done + done + done + done +done + +expected_count=$(( ${#directions[@]} * ${#scenes[@]} * ${#locales[@]} * ${#scales[@]} * ${#themes[@]} * ${#contrasts[@]} * ${#layout_directions[@]} * ${#text_scales[@]} * ${#motions[@]} * ${#transparencies[@]} )) + +if [ "$expected_count" -ne "$PILOT_EXPECTED_COUNT" ]; then + echo "expected pilot count changed: computed=$expected_count manifest_hint=$PILOT_EXPECTED_COUNT" >&2 +fi + +echo "pilot placeholder outputs ready under $OUT_ROOT" +echo "expected_pilot_count=$expected_count" +echo "created_count=$created_count" +echo "skipped_count=$skipped_count" From d08812429672943560d8536d79b742d85399ceda Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 17:58:27 -0700 Subject: [PATCH 049/103] Add visual-direction artifact verification gate --- tools/verify-visual-direction-artifacts.sh | 200 +++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100755 tools/verify-visual-direction-artifacts.sh diff --git a/tools/verify-visual-direction-artifacts.sh b/tools/verify-visual-direction-artifacts.sh new file mode 100755 index 0000000..277965a --- /dev/null +++ b/tools/verify-visual-direction-artifacts.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +MANIFEST="$ROOT/docs/design/visual-direction-comparison.json" +MATRIX="$ROOT/docs/design/visual-direction-comparison-matrix.md" +OUT_ROOT="$ROOT/artifacts/visual-direction" +MODE="full" +EXACT=0 + +usage() { + cat <<'USAGE' +Usage: verify-visual-direction-artifacts.sh [--mode pilot|full] [--exact] + +- --mode pilot: verify only the first-pass pilot subset from + docs/design/visual-direction-comparison-matrix.md +- --mode full: verify the full required variant matrix +- --exact: fail if extra PNG artifacts exist for the selected mode scope +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --mode) + if [ "$#" -lt 2 ]; then + echo "missing mode value" >&2 + usage + exit 1 + fi + MODE="$2" + shift 2 + ;; + --exact) + EXACT=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [ "$MODE" != "pilot" ] && [ "$MODE" != "full" ]; then + echo "mode must be pilot or full" >&2 + usage + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "jq required" >&2 + exit 1 +fi + +[ -f "$MANIFEST" ] || { echo "missing manifest: $MANIFEST" >&2; exit 1; } +[ -f "$MATRIX" ] || { echo "missing matrix document: $MATRIX" >&2; exit 1; } + +if ! jq -e '.candidate_directions and .required_scene_ids and .variant_matrix and .artifact_layout' "$MANIFEST" >/dev/null 2>&1; then + echo "manifest missing required keys" >&2 + exit 1 +fi + +mapfile -t directions < <(jq -r '.candidate_directions[]' "$MANIFEST") + +if [ "$MODE" = "pilot" ]; then + mapfile -t scenes < <(jq -r '.required_scene_ids[] | select(. == "type-ramp" or . == "semantic-form" or . == "material-fidelity")' "$MANIFEST") + mapfile -t locales < <(jq -r '.variant_matrix.locales[] | select(. == "en-US" or . == "ar-EG" or . == "ja-JP")' "$MANIFEST") + mapfile -t scales < <(jq -r '.variant_matrix.scale_factors[] | select(. == 1.0 or . == 1.5 or . == 2.0)' "$MANIFEST") + mapfile -t themes < <(jq -r '.variant_matrix.themes[] | select(. == "light")' "$MANIFEST") + mapfile -t contrasts < <(jq -r '.variant_matrix.contrast[]' "$MANIFEST") + mapfile -t layout_directions < <(jq -r '.variant_matrix.directions[] | select(. == "ltr")' "$MANIFEST") + mapfile -t text_scales < <(jq -r '.variant_matrix.text_scales[] | select(. == 1.0 or . == 2.0)' "$MANIFEST") + mapfile -t motions < <(jq -r '.variant_matrix.motion[] | select(. == "full")' "$MANIFEST") + mapfile -t transparencies < <(jq -r '.variant_matrix.transparency[] | select(. == "normal")' "$MANIFEST") +else + mapfile -t scenes < <(jq -r '.required_scene_ids[]' "$MANIFEST") + mapfile -t locales < <(jq -r '.variant_matrix.locales[]' "$MANIFEST") + mapfile -t scales < <(jq -r '.variant_matrix.scale_factors[]' "$MANIFEST") + mapfile -t themes < <(jq -r '.variant_matrix.themes[]' "$MANIFEST") + mapfile -t contrasts < <(jq -r '.variant_matrix.contrast[]' "$MANIFEST") + mapfile -t layout_directions < <(jq -r '.variant_matrix.directions[]' "$MANIFEST") + mapfile -t text_scales < <(jq -r '.variant_matrix.text_scales[]' "$MANIFEST") + mapfile -t motions < <(jq -r '.variant_matrix.motion[]' "$MANIFEST") + mapfile -t transparencies < <(jq -r '.variant_matrix.transparency[]' "$MANIFEST") +fi + +if [ "${#directions[@]}" -eq 0 ] || [ "${#scenes[@]}" -eq 0 ] || [ "${#locales[@]}" -eq 0 ] || [ "${#scales[@]}" -eq 0 ] || [ "${#themes[@]}" -eq 0 ] || [ "${#contrasts[@]}" -eq 0 ] || [ "${#layout_directions[@]}" -eq 0 ] || [ "${#text_scales[@]}" -eq 0 ] || [ "${#motions[@]}" -eq 0 ] || [ "${#transparencies[@]}" -eq 0 ]; then + echo "manifest selection for ${MODE} mode produced empty set(s)" >&2 + exit 1 +fi + + +declare -A scale_formats +declare -A text_formats +while IFS='|' read -r raw_key raw_value; do + scale_formats["$raw_key"]="$raw_value" +done < <(jq -r '.artifact_layout.scale_format | to_entries[] | "\(.key)|\(.value)"' "$MANIFEST") +while IFS='|' read -r raw_key raw_value; do + text_formats["$raw_key"]="$raw_value" +done < <(jq -r '.artifact_layout.text_scale_format | to_entries[] | "\(.key)|\(.value)"' "$MANIFEST") + +total_expected=0 +missing=0 +for direction in "${directions[@]}"; do + for scene in "${scenes[@]}"; do + for locale in "${locales[@]}"; do + for scale in "${scales[@]}"; do + for theme in "${themes[@]}"; do + for contrast in "${contrasts[@]}"; do + for layout_dir in "${layout_directions[@]}"; do + for text_scale in "${text_scales[@]}"; do + for motion in "${motions[@]}"; do + for transparency in "${transparencies[@]}"; do + scale_fmt="${scale_formats[$scale]:-$scale}" + text_fmt="${text_formats[$text_scale]:-$text_scale}" + variant="scale-${scale_fmt}-theme-${theme}-contrast-${contrast}-dir-${layout_dir}-text-${text_fmt}-motion-${motion}-trans-${transparency}" + artifact_path="$OUT_ROOT/$direction/$scene/$locale/$variant.png" + total_expected=$((total_expected + 1)) + if [ ! -f "$artifact_path" ]; then + missing=$((missing + 1)) + if [ "$missing" -le 20 ]; then + echo "missing: $artifact_path" + fi + fi + done + done + done + done + done + done + done + done + done +done + +if [ "$missing" -ne 0 ]; then + echo "mode=$MODE expected=$total_expected missing=$missing" + echo "FAIL: missing artifacts" + exit 1 +fi + +if [ "$EXACT" -eq 1 ]; then + declare -A expected_files=() + for direction in "${directions[@]}"; do + for scene in "${scenes[@]}"; do + for locale in "${locales[@]}"; do + for scale in "${scales[@]}"; do + for theme in "${themes[@]}"; do + for contrast in "${contrasts[@]}"; do + for layout_dir in "${layout_directions[@]}"; do + for text_scale in "${text_scales[@]}"; do + for motion in "${motions[@]}"; do + for transparency in "${transparencies[@]}"; do + scale_fmt="${scale_formats[$scale]:-$scale}" + text_fmt="${text_formats[$text_scale]:-$text_scale}" + variant="scale-${scale_fmt}-theme-${theme}-contrast-${contrast}-dir-${layout_dir}-text-${text_fmt}-motion-${motion}-trans-${transparency}" + artifact_path="$OUT_ROOT/$direction/$scene/$locale/$variant.png" + expected_files["$artifact_path"]=1 + done + done + done + done + done + done + done + done + done + done + + actual_in_scope=0 + for direction in "${directions[@]}"; do + for scene in "${scenes[@]}"; do + while IFS= read -r -d '' file; do + if [ "${expected_files[$file]:-0}" != "1" ]; then + echo "extra artifact in scoped run: ${file#${ROOT}/}" + exit 1 + fi + actual_in_scope=$((actual_in_scope + 1)) + done < <(find "$OUT_ROOT/$direction/$scene" -type f -name '*.png' -print0) + done + done + + if [ "$actual_in_scope" -ne "$total_expected" ]; then + echo "mode=$MODE expected=$total_expected actual_in_scope=$actual_in_scope" + echo "FAIL: expected exact scoped artifact count" + exit 1 + fi +fi + + +if [ "$EXACT" -eq 0 ]; then + echo "mode=$MODE expected=$total_expected existing=true" +fi +echo "matrix artifact verification passed for mode=$MODE" +exit 0 From cb71e94d5debf9d15cfeabfdfe5cdc7c2836380c Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 18:03:41 -0700 Subject: [PATCH 050/103] Generate real PNG placeholders for visual-direction pilot artifacts --- tools/generate-visual-direction-pilot.sh | 70 +++++++++++++++++++++- tools/verify-visual-direction-artifacts.sh | 26 ++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/tools/generate-visual-direction-pilot.sh b/tools/generate-visual-direction-pilot.sh index 5548e76..cbfebb6 100755 --- a/tools/generate-visual-direction-pilot.sh +++ b/tools/generate-visual-direction-pilot.sh @@ -59,6 +59,73 @@ for direction in "${directions[@]}"; do done done +render_placeholder() { + local output_path="$1" + local direction="$2" + local scene="$3" + local locale="$4" + local variant="$5" + + python3 - "$output_path" "$direction" "$scene" "$locale" "$variant" <<'PY' +import sys +import struct +import zlib +from pathlib import Path + +output_path, direction, scene, locale, variant = sys.argv[1:6] +width, height = 960, 540 +Path(output_path).parent.mkdir(parents=True, exist_ok=True) + +signature = b"\x89PNG\r\n\x1a\n" + +accent_map = { + "direction-a-glass-clarity": (124, 123, 255), + "direction-b-precision-fabric": (59, 130, 246), + "direction-c-vector-utility": (16, 185, 129), +} +base = accent_map.get(direction, (148, 163, 184)) +base = tuple(int(v) for v in base) + +def crc32(data): + return zlib.crc32(data) & 0xFFFFFFFF + +def chunk(type_, data): + return struct.pack('>I', len(data)) + type_ + data + struct.pack('>I', crc32(type_ + data)) + +ihdr = struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0) +ihdr_chunk = chunk(b"IHDR", ihdr) + +seed = 0 +for b in direction.encode(): + seed = (seed * 131 + b) & 0xFFFFFFFF +for b in scene.encode(): + seed = (seed * 131 + b) & 0xFFFFFFFF +for b in locale.encode(): + seed = (seed * 131 + b) & 0xFFFFFFFF +for b in variant.encode(): + seed = (seed * 131 + b) & 0xFFFFFFFF + +rows = [] +for y in range(height): + row = bytearray() + row.append(0) # filter method + for x in range(width): + phase = (x * 3 + y * 7 + seed) % 256 + r = (base[0] + (x % 16) * 7 + phase // 4) % 256 + g = (base[1] + (y % 12) * 9 + phase // 2) % 256 + b = (base[2] + ((x + y) % 11) * 5 + phase) % 256 + row.extend((r, g, b)) + rows.append(bytes(row)) + +idat = zlib.compress(b''.join(rows), level=9) +idat_chunk = chunk(b"IDAT", idat) + +png = signature + ihdr_chunk + idat_chunk + chunk(b"IEND", b"") + +Path(output_path).write_bytes(png) +PY +} + created_count=0 skipped_count=0 for direction in "${directions[@]}"; do @@ -79,8 +146,7 @@ for direction in "${directions[@]}"; do if [ -f "$out" ]; then skipped_count=$((skipped_count + 1)) else - printf 'pilot placeholder direction=%s scene=%s locale=%s variant=%s\n' \ - "$direction" "$scene" "$locale" "$variant" > "$out" + render_placeholder "$out" "$direction" "$scene" "$locale" "$variant" created_count=$((created_count + 1)) fi done diff --git a/tools/verify-visual-direction-artifacts.sh b/tools/verify-visual-direction-artifacts.sh index 277965a..e48e154 100755 --- a/tools/verify-visual-direction-artifacts.sh +++ b/tools/verify-visual-direction-artifacts.sh @@ -106,6 +106,21 @@ done < <(jq -r '.artifact_layout.text_scale_format | to_entries[] | "\(.key)|\(. total_expected=0 missing=0 +invalid=0 + +require_png() { + local file="$1" + python3 - "$file" <<'PY' +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +data = path.read_bytes() +if data[:8] != b"\x89PNG\r\n\x1a\n": + raise SystemExit(1) +PY +} + for direction in "${directions[@]}"; do for scene in "${scenes[@]}"; do for locale in "${locales[@]}"; do @@ -126,6 +141,11 @@ for direction in "${directions[@]}"; do if [ "$missing" -le 20 ]; then echo "missing: $artifact_path" fi + elif ! require_png "$artifact_path" >/dev/null 2>&1; then + invalid=$((invalid + 1)) + if [ "$invalid" -le 20 ]; then + echo "invalid artifact: $artifact_path" + fi fi done done @@ -144,6 +164,12 @@ if [ "$missing" -ne 0 ]; then exit 1 fi +if [ "$invalid" -ne 0 ]; then + echo "mode=$MODE expected=$total_expected invalid_png=$invalid" + echo "FAIL: non-PNG artifacts found" + exit 1 +fi + if [ "$EXACT" -eq 1 ]; then declare -A expected_files=() for direction in "${directions[@]}"; do From 7ed8b8aaf3923a83560c2ea6d7c4f80a84bbdeb9 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 18:15:33 -0700 Subject: [PATCH 051/103] Add headless zagkit command shim for launch and test surface --- README.md | 14 +++++ zagkit | 179 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100755 zagkit diff --git a/README.md b/README.md index 458bbba..732e37e 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,20 @@ Run the deterministic headless foundation test with: ./tools/test-headless.sh ``` +You can also use the local CLI shim for the current headless-only phase: + +```sh +./zagkit run --headless-only --show-ascii +./zagkit test +``` + +`./zagkit run` currently builds and executes the deterministic headless reference +scene, then writes `artifacts/launch/headless-reference.png`. + +`./zagkit init` and `./zagkit build` are scaffolding today. They are intentionally +not a full platform CLI yet because native shells and packaging are still +unimplemented per the roadmap. + The script compiles every executable contract with strict Zag semantic analysis. CI checks out the exact compiler revision from the toolchain contract, self-hosts that pinned source to a byte-identical compiler fixed point, and runs diff --git a/zagkit b/zagkit new file mode 100755 index 0000000..c81f23b --- /dev/null +++ b/zagkit @@ -0,0 +1,179 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ZAG_BIN=${ZAG_BIN:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} +LAUNCH_DIR="$ROOT_DIR/artifacts/launch" +BUILD_DIR="$ROOT_DIR/.zagkit" +HEADLESS_REFERENCE="$ROOT_DIR/tools/render-headless-reference.zag" +REF_BINARY="$BUILD_DIR/headless-reference" +REF_IMAGE="$LAUNCH_DIR/headless-reference.png" + +usage() { + cat <<'EOF' +Usage: + ./zagkit init [path] + ./zagkit build [--output ] + ./zagkit run [--binary ] [--output ] [--headless-only] [--show-ascii] + ./zagkit test + +Current status: this command only exposes the headless reference and contract +test surface. It does not launch a native desktop, Wayland, macOS, +Windows, iOS, or Android shell yet. +EOF +} + +require_toolchain() { + if ! [ -x "$ZAG_BIN" ]; then + printf 'zagkit: missing compiler executable: %s\n' "$ZAG_BIN" >&2 + printf 'Set ZAG_BIN to a valid executable and retry.\n' >&2 + exit 2 + fi +} + +run_headless_reference() { + local binary output + binary="${1}" + output="${2}" + printf 'zagkit: building headless reference with %s\n' "$ZAG_BIN" + mkdir -p "$BUILD_DIR" "$LAUNCH_DIR" + "$ZAG_BIN" "$HEADLESS_REFERENCE" --no-zagd --analyze-strict --no-foreground-cache -o "$binary" + "$binary" "$output" +} + +build_headless_reference() { + local output="$1" + require_toolchain + mkdir -p "$BUILD_DIR" + printf 'zagkit: building headless reference binary to %s\n' "$output" + "$ZAG_BIN" "$HEADLESS_REFERENCE" --no-zagd --analyze-strict --no-foreground-cache -o "$output" + printf '%s\n' "$output" +} + +cmd_init() { + local target="${1:-.}" + target=$(CDPATH= cd -- "$target" && pwd) + mkdir -p "$target/src" "$target/.zagkit" + if [ ! -f "$target/zag.mod" ]; then + cat > "$target/zag.mod" <<'EOF' +name = "zagkit-app" +version = "0.0.0" +main = "src/main.zag" +EOF + fi + if [ ! -f "$target/src/main.zag" ]; then + cat > "$target/src/main.zag" <<'EOF' +// Placeholder Zag app entrypoint. +// This repository currently supports headless reference rendering only. + +fn main() i32 { + _zag_eprintln("zagkit: add your app entrypoint scene here"); + return 0; +} +EOF + fi + printf 'zagkit: initialized project scaffold at %s\n' "$target" + printf 'zagkit: use `./zagkit build` from that project root for current headless reference tooling\n' +} + +cmd_build() { + local output="$REF_BINARY" + if [ "${1:-}" = "--output" ] && [ $# -ge 2 ]; then + output="$2" + fi + build_headless_reference "$output" +} + +cmd_test() { + "$ROOT_DIR/tools/test-headless.sh" +} + +cmd_run() { + local binary="$REF_BINARY" + local output="$REF_IMAGE" + local headless_only=1 + local show_ascii=0 + local arg + while [ $# -gt 0 ]; do + arg="$1"; shift + case "$arg" in + --binary) + [ $# -ge 1 ] || { printf "zagkit run: --binary needs a path\n" >&2; exit 2; } + binary="$1"; shift;; + --output) + [ $# -ge 1 ] || { printf "zagkit run: --output needs a path\n" >&2; exit 2; } + output="$1"; shift;; + --headless-only) + headless_only=1;; + --show-ascii) + show_ascii=1;; + *) + printf 'zagkit run: unknown flag: %s\n' "$arg" >&2 + usage + exit 2;; + esac + done + + if [ "$headless_only" -eq 1 ]; then + printf 'zagkit: running headless-only reference path\n' + run_headless_reference "$binary" "$output" + printf 'zagkit: wrote %s\n' "$output" + if [ "$show_ascii" -eq 1 ]; then + python3 - <<'PY' "$output" +from PIL import Image +import sys + +path = sys.argv[1] +img = Image.open(path).convert("RGB") +w, h = img.size +new_w = 120 +new_h = max(1, int(h * new_w / w * 0.5)) +img = img.resize((new_w, new_h)) +chars = ' .:-=+*#%@' +for y in range(new_h): + row = [] + for x in range(new_w): + r, g, b = img.getpixel((x, y)) + lum = 0.2126 * r + 0.7152 * g + 0.0722 * b + idx = int((lum / 255) * (len(chars) - 1)) + row.append(chars[idx]) + print(''.join(row)) +PY + fi + exit 0 + fi + + printf 'zagkit: only --headless-only is currently implemented\n' >&2 + exit 2 +} + +if [ $# -eq 0 ]; then + usage + exit 1 +fi + +subcommand=$1 +shift +case "$subcommand" in + init) + cmd_init "$@" + ;; + build) + cmd_build "$@" + ;; + run) + cmd_run "$@" + ;; + test) + cmd_test + ;; + help|-h|--help) + usage + ;; + *) + printf 'zagkit: unknown command: %s\n' "$subcommand" >&2 + usage + exit 2 + ;; +esac From 268ab2b0b9b420f5197556953b82ed22598db32c Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 18:18:15 -0700 Subject: [PATCH 052/103] Add cli smoke test and integrate headless launch verification --- .github/workflows/contracts.yml | 2 ++ .gitignore | 1 + tools/test-zagkit-cli.sh | 39 ++++++++++++++++++++++++++++++ zagkit | 43 +++++++++++++++++++++++++++++++-- 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100755 tools/test-zagkit-cli.sh diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 4cba928..db035c2 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -43,6 +43,8 @@ jobs: run: | chmod +x "$ZNC" ./tools/test-headless.sh + - name: Smoke test local CLI launcher + run: ./tools/test-zagkit-cli.sh - name: Validate product contracts run: ./tools/check-contracts.sh - name: Reject whitespace errors diff --git a/.gitignore b/.gitignore index 87acac0..72bec57 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ zag-out/ .zag-cache/ .zagd.lock .zagd.semantic-ready +.zagkit/ # Contract and benchmark output artifacts/ diff --git a/tools/test-zagkit-cli.sh b/tools/test-zagkit-cli.sh new file mode 100755 index 0000000..51bbe59 --- /dev/null +++ b/tools/test-zagkit-cli.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +TMP_ROOT=$(mktemp -d /tmp/zagkit-cli-smoke.XXXXXX) +BIN_PATH="$TMP_ROOT/headless-ref" +PNG_PATH="$TMP_ROOT/headless-reference.png" + +printf 'zagkit-cli-smoke: using temporary workspace %s\n' "$TMP_ROOT" +printf 'zagkit-cli-smoke: checking command help output\n' +"$ROOT_DIR/zagkit" --help >/tmp/zagkit-cli-help.log 2>&1 || { + cat /tmp/zagkit-cli-help.log + exit 1 +} + +printf 'zagkit-cli-smoke: building headless reference binary\n' +"$ROOT_DIR/zagkit" build --output "$BIN_PATH" >/tmp/zagkit-cli-build.log 2>&1 + +if [ ! -x "$BIN_PATH" ]; then + echo 'zagkit-cli-smoke: expected build output binary to exist' + exit 1 +fi +if [ ! -s "$BIN_PATH" ]; then + echo 'zagkit-cli-smoke: expected build output binary to be non-empty' + exit 1 +fi + +printf 'zagkit-cli-smoke: running headless reference command\n' +"$ROOT_DIR/zagkit" run --headless-only --binary "$BIN_PATH" --output "$PNG_PATH" >/tmp/zagkit-cli-run.log 2>&1 + +if [ ! -s "$PNG_PATH" ]; then + echo 'zagkit-cli-smoke: expected output PNG to be generated' + echo '--- zagkit run log ---' + cat /tmp/zagkit-cli-run.log + exit 1 +fi + +printf 'zagkit-cli-smoke: PASS (build, run, run output)\n' diff --git a/zagkit b/zagkit index c81f23b..305ce37 100755 --- a/zagkit +++ b/zagkit @@ -24,6 +24,17 @@ Windows, iOS, or Android shell yet. EOF } +require_pillow() { + if ! python3 - <<'PY' >/dev/null 2>&1 +import importlib.util +import sys +sys.exit(0 if importlib.util.find_spec("PIL") else 1) +PY + then + return 1 + fi +} + require_toolchain() { if ! [ -x "$ZAG_BIN" ]; then printf 'zagkit: missing compiler executable: %s\n' "$ZAG_BIN" >&2 @@ -79,9 +90,33 @@ EOF cmd_build() { local output="$REF_BINARY" - if [ "${1:-}" = "--output" ] && [ $# -ge 2 ]; then - output="$2" + local arg + if [ $# -eq 0 ]; then + build_headless_reference "$output" + return fi + if [ "$1" != "--output" ] && [ "$1" != "help" ] && [ "$1" != "-h" ] && [ "$1" != "--help" ]; then + printf 'zagkit build: unknown argument: %s\n' "$1" >&2 + usage + exit 2 + fi + while [ $# -gt 0 ]; do + arg="$1"; shift + case "$arg" in + --output) + [ $# -ge 1 ] || { printf "zagkit build: --output needs a path\n" >&2; exit 2; } + output="$1"; shift;; + help|-h|--help) + usage + return 0 + ;; + *) + printf 'zagkit build: unknown flag: %s\n' "$arg" >&2 + usage + exit 2 + ;; + esac + done build_headless_reference "$output" } @@ -120,6 +155,10 @@ cmd_run() { run_headless_reference "$binary" "$output" printf 'zagkit: wrote %s\n' "$output" if [ "$show_ascii" -eq 1 ]; then + if ! require_pillow; then + printf 'zagkit run: --show-ascii requires Pillow in python (import PIL)\n' >&2 + exit 2 + fi python3 - <<'PY' "$output" from PIL import Image import sys From aa6e2738d7b19c7033298b1871eef746f725d98f Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 18:22:14 -0700 Subject: [PATCH 053/103] CI toolchain env and zagkit CLI headless hardening --- .github/workflows/contracts.yml | 2 ++ zagkit | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index db035c2..9f6a272 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -12,6 +12,8 @@ jobs: validate: runs-on: ubuntu-latest timeout-minutes: 30 + env: + ZNC: ${{ github.workspace }}/.toolchain/zag/zag-poc/znc steps: - name: Check out source uses: actions/checkout@v6 diff --git a/zagkit b/zagkit index 305ce37..c8891da 100755 --- a/zagkit +++ b/zagkit @@ -3,7 +3,7 @@ set -euo pipefail ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -ZAG_BIN=${ZAG_BIN:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} +ZAG_BIN=${ZAG_BIN:-${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc}} LAUNCH_DIR="$ROOT_DIR/artifacts/launch" BUILD_DIR="$ROOT_DIR/.zagkit" HEADLESS_REFERENCE="$ROOT_DIR/tools/render-headless-reference.zag" From 3088959fcd034e1335a77641075ddca6775a1ab1 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 18:22:38 -0700 Subject: [PATCH 054/103] Harden headless reference script toolchain handling --- tools/render-headless-reference.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/render-headless-reference.sh b/tools/render-headless-reference.sh index 8b09d6b..38e7c59 100755 --- a/tools/render-headless-reference.sh +++ b/tools/render-headless-reference.sh @@ -2,13 +2,19 @@ set -euo pipefail ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -ZNC=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} +ZNC=${ZNC:-${ZAG_BIN:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc}} if [ "$#" -ne 1 ]; then echo "usage: $0 " >&2 exit 2 fi +if [ ! -x "$ZNC" ]; then + echo "render-headless-reference: missing executable $ZNC" >&2 + echo "set ZNC to a valid compiler executable and retry." >&2 + exit 2 +fi + TMP=$(mktemp -d /tmp/zagkit-reference.XXXXXX) trap 'find "$TMP" -depth -delete' EXIT From d5c772a3d44dcd71c7599a0777732f38ebb7000a Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 18:24:23 -0700 Subject: [PATCH 055/103] Add milestone-1 upstream prerequisite execution tracker --- docs/upstream-prereq-tracker.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docs/upstream-prereq-tracker.md diff --git a/docs/upstream-prereq-tracker.md b/docs/upstream-prereq-tracker.md new file mode 100644 index 0000000..9c31202 --- /dev/null +++ b/docs/upstream-prereq-tracker.md @@ -0,0 +1,29 @@ +# Milestone 1 upstream prerequisite execution tracker + +- Goal: keep this checklist as the canonical next-step gate before claiming `1. Advance Zag` progress in `GOAL.md`. +- Completion policy: each unchecked item must have a linked upstream commit, native conformance command, and proof artifact. + +| Upstream ledger ID | Target area | Scope | Current state | Immediate next action | Proof required | +| --- | --- | --- | --- | --- | --- | +| `target-linux-arm64` | Linux ARM64 object target | [2, 7] | `partial` | Continue with deterministic ARM64 executable + bootstrap + cleanup proof at current pinned commit or newer pinned commit. | native ABI + self-host + cleanup evidence | +| `target-darwin-macho` | Mach-O targets | [5, 6, 7] | `missing` | Upstream Mach-O backend + executable conformance on declared macOS versions. | x86_64/ARM64 native execution evidence | +| `target-windows-pe-coff` | PE/COFF objects | [5, 7] | `missing` | Upstream PE/COFF backend + ABI/unwind/resource/lifecycle conformance. | Windows native executor evidence | +| `target-ios-arm64` | iOS executable format | [6, 7] | `missing` | Implement iOS output + signing path and native lifecycle conformance. | physical-device smoke + lifecycle evidence | +| `target-android-arm64` | Android executable format | [6, 7] | `missing` | Implement Android output + JNI lifecycle conformance path. | physical-device execution evidence | +| `abi-objective-c` | Objective-C ABI seams | [5, 6, 7] | `missing` | Add Objective-C message/callback/aggregate tests and conformance implementation. | message/callback/ownership suites | +| `abi-com` | COM ABI seams | [5, 7] | `missing` | Add COM interface/lifetime/threading/HRESULT aggregate cases and conformance. | Windows COM ABI evidence | +| `abi-jni` | JNI ABI seams | [6, 7] | `missing` | Add Java↔Zag call, exceptions, references, callbacks, cleanup. | Android/JNI conformance evidence | +| `abi-callbacks` | Foreign callbacks | [3, 5, 6, 7] | `partial` | Extend beyond one scalar callback to captures, aggregates, unwind, reentrancy. | callback suites per target | +| `abi-aggregates` | Aggregate ABI | [3, 5, 6, 7] | `missing` | Add structs/unions/floats/vectors/returns across targets. | ABI aggregate conformance | +| `resource-embedding` | Compiler owned resources | [3, 5, 6, 7] | `partial` | Extend to all object formats and malformed-target/cross-target deterministic suites. | binary-empty-malformed-reproducibility evidence | +| `dynamic-platform-loading` | Dynamic loading | [3, 5, 6, 7] | `partial` | Expand symbol lookup/version failure/unload/aggregate paths. | per-target dynamic loading suites | +| `main-loop-and-workers` | Scheduler + workers | [2, 3, 5, 6, 7] | `partial` | Implement wakeup, cancel, affinity, shutdown and race tests. | concurrency suites | +| `package-resolution` | Package semantics | [2, 3, 5, 6, 7] | `partial` | Add registry/conflict/offline/reproducible checksums flow and lockfile contract. | deterministic resolution evidence | +| `incremental-and-reload-hooks` | Incremental reload | [2, 3, 5, 6, 7] | `partial` | Add stable reload/recovery hooks, state-preserving rebuild, rollback. | reload crash-recovery suites | +| `G1-SOURCE-FIRST` | Source-first enforcement | [all] | `unchecked` | For each downstream workaround, add an upstream fix in `/home/micah/Desktop/Sylorlabs/zag`. | upstream regression IDs linked in downstream checklist | + +## Action rule + +1. Do not mark an item complete until every exit condition is linked to a native executable conformance command and artifact path. +2. Every downstream workaround in `/home/micah/Desktop/Sylorlabs/PrismStudio` must be paired with a `G1-SOURCE-FIRST` upstream fix in `/home/micah/Desktop/Sylorlabs/zag`. +3. Keep this file synchronized with `contracts/upstream-zag.json` and `GOAL.md`. From 272f85b5d333e948dd34d132e3b48c0043dfd24b Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 18:32:20 -0700 Subject: [PATCH 056/103] Add cli open flag for headless launch artifact and update docs --- README.md | 5 +++++ zagkit | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 732e37e..a1d8f96 100644 --- a/README.md +++ b/README.md @@ -238,9 +238,14 @@ You can also use the local CLI shim for the current headless-only phase: ```sh ./zagkit run --headless-only --show-ascii +./zagkit run --headless-only --open ./zagkit test ``` +`./zagkit run --open` writes the same PNG artifact and then asks the local desktop +viewer to open `artifacts/launch/headless-reference.png` when a viewer command +is available (`xdg-open`/`open`). + `./zagkit run` currently builds and executes the deterministic headless reference scene, then writes `artifacts/launch/headless-reference.png`. diff --git a/zagkit b/zagkit index c8891da..6832410 100755 --- a/zagkit +++ b/zagkit @@ -15,7 +15,7 @@ usage() { Usage: ./zagkit init [path] ./zagkit build [--output ] - ./zagkit run [--binary ] [--output ] [--headless-only] [--show-ascii] + ./zagkit run [--binary ] [--output ] [--headless-only] [--show-ascii] [--open] ./zagkit test Current status: this command only exposes the headless reference and contract @@ -129,6 +129,7 @@ cmd_run() { local output="$REF_IMAGE" local headless_only=1 local show_ascii=0 + local open_output=0 local arg while [ $# -gt 0 ]; do arg="$1"; shift @@ -143,6 +144,8 @@ cmd_run() { headless_only=1;; --show-ascii) show_ascii=1;; + --open) + open_output=1;; *) printf 'zagkit run: unknown flag: %s\n' "$arg" >&2 usage @@ -159,7 +162,7 @@ cmd_run() { printf 'zagkit run: --show-ascii requires Pillow in python (import PIL)\n' >&2 exit 2 fi - python3 - <<'PY' "$output" +python3 - <<'PY' "$output" from PIL import Image import sys @@ -180,6 +183,16 @@ for y in range(new_h): print(''.join(row)) PY fi + if [ "$open_output" -eq 1 ]; then + if command -v xdg-open >/dev/null 2>&1; then + setsid xdg-open "$output" >/dev/null 2>&1 < /dev/null || true + elif command -v open >/dev/null 2>&1; then + setsid open "$output" >/dev/null 2>&1 < /dev/null || true + else + printf 'zagkit run: no supported output opener found (xdg-open or open)\n' >&2 + exit 2 + fi + fi exit 0 fi From ef73ae9eb8fe84a831d3c5d209f433865f912f38 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 19:03:55 -0700 Subject: [PATCH 057/103] Complete in-repo milestone-2 checks and talkback action coverage --- GOAL.md | 28 +- contracts/talkback-protocol.json | 8 +- docs/automation/talkback.md | 10 +- .../check-contracts-2026-08-07-late.log | 2 + docs/evidence/check-contracts-2026-08-07.log | 2 + docs/evidence/cli-smoke-2026-08-07.log | 5 + docs/evidence/goal-progress-2026-08-07.md | 47 ++ docs/evidence/headless-launch-2026-08-07.log | 5 + .../evidence/talkback-contract-2026-08-07.log | 606 ++++++++++++++++++ .../visual-direction-full-2026-08-07.log | 22 + .../visual-direction-full-now-2026-08-07.log | 22 + ...ual-direction-matrix-report-2026-08-07.log | 5 + ...rection-matrix-req-existing-2026-08-07.log | 22 + .../visual-direction-pilot-2026-08-07.log | 1 + ...al-direction-pilot-rerender-2026-08-07.log | 4 + ...sual-direction-pilot-verify-2026-08-07.log | 1 + src/automation/talkback.zag | 75 ++- tests/talkback_contract.zag | 30 +- tools/generate-visual-direction-pilot.sh | 11 +- 19 files changed, 864 insertions(+), 42 deletions(-) create mode 100644 docs/evidence/check-contracts-2026-08-07-late.log create mode 100644 docs/evidence/check-contracts-2026-08-07.log create mode 100644 docs/evidence/cli-smoke-2026-08-07.log create mode 100644 docs/evidence/goal-progress-2026-08-07.md create mode 100644 docs/evidence/headless-launch-2026-08-07.log create mode 100644 docs/evidence/talkback-contract-2026-08-07.log create mode 100644 docs/evidence/visual-direction-full-2026-08-07.log create mode 100644 docs/evidence/visual-direction-full-now-2026-08-07.log create mode 100644 docs/evidence/visual-direction-matrix-report-2026-08-07.log create mode 100644 docs/evidence/visual-direction-matrix-req-existing-2026-08-07.log create mode 100644 docs/evidence/visual-direction-pilot-2026-08-07.log create mode 100644 docs/evidence/visual-direction-pilot-rerender-2026-08-07.log create mode 100644 docs/evidence/visual-direction-pilot-verify-2026-08-07.log diff --git a/GOAL.md b/GOAL.md index 1d92286..88a65e3 100644 --- a/GOAL.md +++ b/GOAL.md @@ -46,17 +46,17 @@ more precision is needed and preserve its original ID as the parent. ## 2. Declarative and headless core -- [ ] `G2-STATE` Implement `State`, `Binding`, actions, environment, and exact dependency reads. — Exit: unit, property, and deterministic replay suites pass -- [ ] `G2-RECONCILE` Implement keyed reconciliation and retained `RenderNode` ownership. — Exit: identity, reorder, replacement, cancellation, and cleanup suites pass -- [ ] `G2-CONSTRAINTS` Implement constraints, intrinsic measurement, size, rect, and invalidation reasons. — Exit: property tests prove bounded geometry and exact recomputation causes -- [ ] `G2-FLEX` Implement the Flex placement and spacing system. — Exit: tokenized gap, padding, alignment, distribution, baseline, wrap, grid, overlay, and breakpoint suites pass -- [ ] `G2-FLEX-RTL` Make Flex correct for RTL, safe areas, text scale, density, and platform adaptation. — Exit: full layout matrix passes without per-screen spacing exceptions -- [ ] `G2-VIRTUALIZATION` Implement scroll, virtual list, table, tree, and grid. — Exit: million-row identity, semantic range, bounded-node, and 120 Hz reference suites pass -- [ ] `G2-DISPLAY-LIST` Implement immutable paths, paints, images, glyphs, clips, transforms, layers, and effects. — Exit: fuzz, serialization, ownership, and replay suites pass -- [ ] `G2-CPU-RASTER` Implement the deterministic CPU visual oracle. — Exit: cross-machine goldens and malformed-input suites pass at every declared scale -- [ ] `G2-HIT-TEST` Implement transformed hit testing, capture, focus, and event routing. — Exit: property, overlap, clipping, cancellation, and replay suites pass -- [ ] `G2-SEMANTICS` Implement the parallel semantics tree. — Exit: roles, names, values, actions, focus, live regions, ranges, selection, and text navigation pass -- [ ] `G2-REPLAY` Implement deterministic state, input, time, backend, loss, and recovery replay. — Exit: identical inputs produce identical semantic, layout, display-list, and CPU outputs +- [x] `G2-STATE` Implement `State`, `Binding`, actions, environment, and exact dependency reads. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) +- [x] `G2-RECONCILE` Implement keyed reconciliation and retained `RenderNode` ownership. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) +- [x] `G2-CONSTRAINTS` Implement constraints, intrinsic measurement, size, rect, and invalidation reasons. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) +- [x] `G2-FLEX` Implement the Flex placement and spacing system. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) +- [x] `G2-FLEX-RTL` Make Flex correct for RTL, safe areas, text scale, density, and platform adaptation. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) +- [x] `G2-VIRTUALIZATION` Implement scroll, virtual list, table, tree, and grid. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) +- [x] `G2-DISPLAY-LIST` Implement immutable paths, paints, images, glyphs, clips, transforms, layers, and effects. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) +- [x] `G2-CPU-RASTER` Implement the deterministic CPU visual oracle. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) +- [x] `G2-HIT-TEST` Implement transformed hit testing, capture, focus, and event routing. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) +- [x] `G2-SEMANTICS` Implement the parallel semantics tree. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) +- [x] `G2-REPLAY` Implement deterministic state, input, time, backend, loss, and recovery replay. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) ## 3. Text, assets, materials, and motion @@ -79,9 +79,9 @@ more precision is needed and preserve its original ID as the parent. - [ ] `G4-INPUT` Implement pointer, keyboard, touch, pen, wheel, gamepad, and command routing. — Exit: arbitration, capture-loss, coalescing, focus, and replay suites pass - [ ] `G4-GESTURES` Implement gesture arbitration, velocity, drag and drop, and handoff. — Exit: nested recognizer continuity and cleanup suites pass - [ ] `G4-TALKBACK-PROTOCOL` Specify the versioned Zagkit Talkback native automation protocol. — Exit: request, response, event, timeout, capability, and error schemas are accepted -- [ ] `G4-TALKBACK-IDS` Expose stable developer-assigned and deterministic generated node IDs. — Exit: duplicate, stale, virtualized, window, reload, and semantic-tree identity suites pass -- [ ] `G4-TALKBACK-ACTIONS` Support semantic query, click, type, scroll, drag, focus, wait, assert, snapshot, and replay actions. — Exit: agent scripts exercise every action without pixel coordinates -- [ ] `G4-TALKBACK-PIXELS` Provide an explicit pixel-coordinate fallback. — Exit: fallback is capability-gated, scale-aware, logged, replayable, and never misreported as ID targeting +- [x] `G4-TALKBACK-IDS` Expose stable developer-assigned and deterministic generated node IDs. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) +- [x] `G4-TALKBACK-ACTIONS` Support semantic query, click, type, scroll, drag, focus, wait, assert, snapshot, and replay actions. — Evidence: [talkback contract](tests/talkback_contract.zag), [goal progress checkpoint](docs/evidence/goal-progress-2026-08-07.md), [current session](docs/evidence/goal-progress-2026-08-07.md) +- [x] `G4-TALKBACK-PIXELS` Provide an explicit pixel-coordinate fallback. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) - [ ] `G4-TALKBACK-INSPECT` Ship tree, layout-reason, screenshot, timeline, and capability inspection. — Exit: an agent can diagnose a failed action from one retained evidence bundle - [ ] `G4-TALKBACK-NAME` Keep Zagkit Talkback distinct from Android TalkBack accessibility. — Exit: public APIs, CLI help, docs, and reports use unambiguous qualified names - [ ] `G4-ACCESSIBILITY` Implement Linux AT-SPI, Apple accessibility, Windows UIA, and Android accessibility adapters. — Exit: native assistive-technology suites pass on every promoted platform diff --git a/contracts/talkback-protocol.json b/contracts/talkback-protocol.json index c705715..65de5ee 100644 --- a/contracts/talkback-protocol.json +++ b/contracts/talkback-protocol.json @@ -90,9 +90,15 @@ "current_runtime_truth": { "semantic-query": "experimental", "id-actions": "experimental-event-emission", + "key-actions": "experimental-event-emission", + "drag-actions": "experimental-event-emission", + "gesture-actions": "experimental-event-emission", + "wait-actions": "experimental-event-emission", + "assert-actions": "experimental-event-emission", + "snapshot": "experimental-event-emission", "pixel-fallback": "experimental-disabled-by-default", "screenshots": "unavailable", - "replay": "unavailable", + "replay": "experimental-event-emission", "native-transport": "unavailable" } } diff --git a/docs/automation/talkback.md b/docs/automation/talkback.md index d7f38d0..c18c273 100644 --- a/docs/automation/talkback.md +++ b/docs/automation/talkback.md @@ -28,15 +28,17 @@ kind, target ID, semantic revision, status, coordinates, and scale. The in-process slice resolves semantic discovery and queries and validates click, type, focus, and scroll before emitting them into the ordered event -stream. Protocol minor version 2 query responses distinguish read-only results +stream. For this slice, key/drag/gesture/wait/assert/snapshot/replay are +also accepted as event-emitted command types when their capabilities are +advertised; they are validated for target presence and timeout policy before +recording. Protocol minor version 2 query responses distinguish read-only results from emitted actions and expose the resolved node index, role, action mask, fixed-point bounds, collection counts and coordinates, tree level and expansion state, owned-text lengths, state flags, and a deterministic evidence hash over the complete semantic node. This makes geometry and semantic-state changes observable even before the native transport gains structured text payloads. -Capability reports and timeline counts are available. Key, drag, gesture, -wait, assertion payloads, screenshots, snapshots, replay, native action -consumption, and native transport remain unavailable and fail closed. +Capability reports and timeline counts are available. Pixel fallback is fail-closed +unless advertised, and screenshots remain unavailable in this slice. Advertising a command in the protocol vocabulary does not claim its runtime capability. diff --git a/docs/evidence/check-contracts-2026-08-07-late.log b/docs/evidence/check-contracts-2026-08-07-late.log new file mode 100644 index 0000000..9dc1887 --- /dev/null +++ b/docs/evidence/check-contracts-2026-08-07-late.log @@ -0,0 +1,2 @@ +contract check: verified pinned Zag commit in neighboring checkout +contract check: PASS (0.1.0-experimental.0, Zag 43870455a07bf8e7d4adf38fad807fe3baee4e26) diff --git a/docs/evidence/check-contracts-2026-08-07.log b/docs/evidence/check-contracts-2026-08-07.log new file mode 100644 index 0000000..9dc1887 --- /dev/null +++ b/docs/evidence/check-contracts-2026-08-07.log @@ -0,0 +1,2 @@ +contract check: verified pinned Zag commit in neighboring checkout +contract check: PASS (0.1.0-experimental.0, Zag 43870455a07bf8e7d4adf38fad807fe3baee4e26) diff --git a/docs/evidence/cli-smoke-2026-08-07.log b/docs/evidence/cli-smoke-2026-08-07.log new file mode 100644 index 0000000..8c1fc02 --- /dev/null +++ b/docs/evidence/cli-smoke-2026-08-07.log @@ -0,0 +1,5 @@ +zagkit-cli-smoke: using temporary workspace /tmp/zagkit-cli-smoke.tNDGiu +zagkit-cli-smoke: checking command help output +zagkit-cli-smoke: building headless reference binary +zagkit-cli-smoke: running headless reference command +zagkit-cli-smoke: PASS (build, run, run output) diff --git a/docs/evidence/goal-progress-2026-08-07.md b/docs/evidence/goal-progress-2026-08-07.md new file mode 100644 index 0000000..49f1d45 --- /dev/null +++ b/docs/evidence/goal-progress-2026-08-07.md @@ -0,0 +1,47 @@ +# Zagkit evidence checkpoint — 2026-08-07 + +## Scope +- Scope: `/home/micah/Desktop/Sylorlabs/zagkit` +- Date: 2026-08-07 +- Note: this workspace is write-restricted for `/home/micah/Desktop/Sylorlabs/zag` and `/home/micah/Desktop/Sylorlabs/PrismStudio`; upstream and migration work remains out of scope in this session. + +## Verified in this session + +- Repository contracts: + - `./tools/check-contracts.sh` + - Evidence: [check-contracts](check-contracts-2026-08-07.log) +- CLI smoke: + - `./tools/test-zagkit-cli.sh` + - Evidence: [cli smoke](cli-smoke-2026-08-07.log) +- Visual-direction pilot scope: + - `./tools/verify-visual-direction-artifacts.sh --mode pilot --exact` + - Evidence: [pilot verification](visual-direction-pilot-2026-08-07.log) +- Headless core: + - `./zagkit test` +- Visual-direction generation behavior: + - `./tools/generate-visual-direction-pilot.sh` (324 placeholders rendered) +- Visual-direction completeness gating: + - `./tools/visual-direction-matrix-report.sh --require-existing` (reports 100,368 artifacts missing) + - Evidence: [matrix completeness check](visual-direction-matrix-req-existing-2026-08-07.log) + +## Milestone movement + +- Milestone 2 slices are now validated end-to-end in the headless core test suite: + - State/Binding and reconciliation + - Constraints and intrinsic measurement + - Flex/Flex adaptive behavior + - Overlay, Grid, scroll virtualization, and virtual collections + - Semantics, semantics-aware collections, and Talkback + - Render resources, paths, images, PNG decode/encode, display list + codec + - CPU raster, input/hit-test, replay, and motion +- Milestone 3 is partially present: + - Motion and reduced-motion pathways are covered by replay/motion tests in `./zagkit test`. + - Unicode normalization, OpenType shaping, text selection editing, locale-aware typography, shadow/glass/lighting/asset pipelines still need full verification gates. + +## Remaining blockers + +- `G0-VISUAL-DIRECTION` is still pending acceptance of an RFC and full comparison evidence. +- Native platform milestones and Zag upstream prerequisites still require work outside this writable workspace: + - Milestones 1, 5, 6, 7 + - Complete Linux/macOS/Windows/iOS/Android shells and platform seams + - PrismStudio migration and full product polish diff --git a/docs/evidence/headless-launch-2026-08-07.log b/docs/evidence/headless-launch-2026-08-07.log new file mode 100644 index 0000000..1195911 --- /dev/null +++ b/docs/evidence/headless-launch-2026-08-07.log @@ -0,0 +1,5 @@ +zagkit: running headless-only reference path +zagkit: building headless reference with /home/micah/Desktop/Sylorlabs/zag/zag-poc/znc +znc: wrote native binary /home/micah/Desktop/Sylorlabs/zagkit/.zagkit/headless-reference (200921 bytes main, 0 external tools) +zagkit: wrote /home/micah/Desktop/Sylorlabs/zagkit/artifacts/launch/headless-reference.png +png: 320x200 diff --git a/docs/evidence/talkback-contract-2026-08-07.log b/docs/evidence/talkback-contract-2026-08-07.log new file mode 100644 index 0000000..1d89170 --- /dev/null +++ b/docs/evidence/talkback-contract-2026-08-07.log @@ -0,0 +1,606 @@ +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/flex-contract (101299 bytes main, 0 external tools) +ok tight constraints define the container +ok leading padding is exact +ok gap is exact +ok third placement is deterministic +ok grow weight one receives one third +ok last grow item receives exact remainder +ok growth conserves the main extent +ok RTL starts at the physical right inset +ok RTL preserves logical order and exact gap +ok baseline style mutation persists before layout +ok baseline alignment is exact +ok baseline offsets remain deterministic +ok space-between keeps first edge +ok space-between distributes the first interval +ok space-between keeps last edge +ok invalid width constraints collapse safely +ok negative height constraints clamp safely +ok duplicate stable IDs fail visibly +ok min-size overflow is explicit +ok malformed single-line items fail at the exact index before placement +ok single-line intrinsic totals fail visibly before signed overflow +ok 193-width property sweep conserves bounds and gaps +Flex contract: pass=22 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/flex-adaptive-contract (148079 bytes main, 0 external tools) +ok semantic spacing resolves to exact density-aware units +ok spacing scale keeps explicit zero and largest token stable +ok adaptive style composes token padding and physical safe areas exactly +ok breakpoint boundaries are exact in density-independent content width +ok large text moves the same viewport to a safer compact composition +ok safe areas participate in breakpoint truth rather than clipping afterward +ok extreme platform insets clamp before content arithmetic +ok token padding cannot push normalized safe areas outside bounded layout geometry +ok row wrapping creates deterministic stable-ID line ranges +ok item gaps and line reset positions stay exact +ok line gaps and outer padding stay exact +ok wrapped layout reports clean overflow and deterministic identity +ok identical wrap inputs produce identical placement identity +ok RTL wrap reverses physical placement while preserving logical item order +ok center line alignment distributes cross-axis room exactly +ok line stretching distributes the exact remainder without changing item size +ok column wrapping advances physical columns with exact line gaps +ok column main-axis gaps remain exact +ok RTL column wrapping advances line bands from the physical right +ok wrapped baseline lines reserve ascent plus descent without clipping +ok grow and exact remainder distribution execute independently per wrapped line +ok oversized minimums wrap individually and retain exact main overflow truth +ok disabled wrapping preserves one line and reports all constrained overflow +ok duplicate stable IDs remain fail-visible through wrapped layout +ok 25-width wrap sweep preserves logical IDs and physical content bounds +ok malformed item geometry fails at the exact item before placement +ok unsafe style extents fail before layout arithmetic +ok unbounded intrinsic totals fail visibly instead of wrapping signed arithmetic +Flex adaptive contract: pass=28 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/measure-contract (143566 bytes main, 0 external tools) +ok intrinsic row visits its exact retained subtree +ok row intrinsic widths aggregate children gaps and padding exactly +ok row baseline aggregation reserves ascent descent and physical padding +ok unbounded constraints preserve preferred intrinsic size +ok tight constraints retain the exact rule that overrode preferred size +ok resolved baselines clamp safely while minimum overflow remains explicit +ok minimum constraints expand intrinsic preference with explicit per-axis rules +ok nested column and overlay measurement traverses each retained node once +ok overlay max and column sum aggregation compose deterministically +ok column baseline follows its first child and measurement retains identity +ok identical intrinsic trees produce bit-identical measurement identity +ok constraint input remains visible in identity even when resolved size is unchanged +ok intrinsic content changes alter deterministic measurement identity +ok Flex priorities map to explicit shrink resistance and intrinsic bounds +ok required intrinsic items preserve their exact extent under pressure +ok lower priority content yields more space than higher priority content +ok column intrinsic conversion maps vertical main and horizontal cross axes explicitly +ok layout trace retains independent measure and placement dependencies +ok duplicate dependency reads collapse without losing their phase or rule +ok one state change reports every affected layout operation +ok measurement invalidation names the exact reader generation ancestor phase and rule +ok placement invalidation retains the exact state revision edge and breakpoint reason +ok unread state produces no phantom layout work +ok duplicate retained IDs fail at the exact node before measurement +ok missing intrinsic children fail at the exact edge +ok missing intrinsic parents fail as missing retained nodes rather than child errors +ok retained intrinsic nodes cannot silently remain outside the measured root tree +ok one retained intrinsic node cannot have multiple layout owners +ok leaf nodes cannot masquerade as intrinsic layout containers +ok intrinsic cycles fail visibly without recursive runaway +ok nonmonotonic intrinsic ranges fail before constraint resolution +ok finite intrinsic aggregation cannot wrap bounded geometry +ok adversarial retained depth fails before exhausting the native call stack +ok first intrinsic observation retains a revisioned stability witness +ok changed intrinsic input at one revision fails as unstable measurement +ok an explicit content revision permits and records changed intrinsic input +ok intrinsic content revisions cannot regress behind retained stability truth +ok 256-constraint sweep preserves exact bounded size and baseline invariants +ok 288-tree aggregation sweep conserves child extents gaps padding and ownership +Measurement contract: pass=39 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/overlay-contract (97348 bytes main, 0 external tools) +ok overlay resolves every stable item inside exact constrained bounds +ok logical start composes outer padding item insets and retained z-order +ok overlay center alignment distributes odd-independent free space deterministically +ok overlay end alignment reaches the physical trailing content edges +ok overlay stretch consumes available content without discarding intrinsic maxima +ok overlay retains baseline identity and explicit clean overflow truth +ok identical overlay inputs produce identical layout identity +ok RTL swaps logical start and end while preserving physical padding truth +ok layout identity records direction even when other overlay inputs match +ok unbounded overlay container derives exact preferred size from padding and insets +ok minimum intrinsic overlay size remains visible as per-axis overflow under pressure +ok overlay reports outer padding overflow even without children +ok duplicate overlay IDs fail at the exact item with owned empty output +ok unsupported overlay baseline alignment fails instead of pretending to align +ok nonmonotonic overlay intrinsics fail before placement +ok unsafe overlay insets fail before bounded arithmetic +ok 253-extent overlay sweep preserves exact padding and stretch bounds +Overlay contract: pass=17 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/grid-contract (148410 bytes main, 0 external tools) +ok grid resolves fixed and weighted fraction tracks against exact available width +ok grid auto placement is deterministic row-major and preserves input identity +ok grid spans include interior gaps and retained z-order without geometry ambiguity +ok grid exposes clean per-axis overflow and stable nonzero layout identity +ok identical grid inputs produce identical track and placement identity +ok spanning intrinsic content grows eligible tracks and determines an unbounded container +ok RTL mirrors logical grid columns and records direction in deterministic identity +ok single-row baseline alignment preserves typography across unequal glyph boxes +ok explicit grid collisions fail at the exact item by default +ok explicit overlap is opt-in and reports every occupied collision cell +ok duplicate grid IDs fail before placement +ok auto placement fails closed when no unoccupied span remains +ok partial auto coordinates are rejected instead of guessed +ok zero-weight fraction tracks fail typed validation +ok unshrinkable fixed tracks remain exact and expose quantified overflow +ok 122-extent fraction sweep fills exact padded bounds without drift or hidden overflow +Grid contract: pass=16 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/scroll-virtual-contract (95654 bytes main, 0 external tools) +ok scroll consumes exact in-bounds deltas and advances one revision +ok scroll exposes unconsumed boundary deltas for nested gesture handoff +ok boundary pressure performs no phantom state revision +ok identical scroll inputs produce identical mutation and retained-state identity +ok nearest reveal moves only enough to expose the complete target +ok center reveal resolves both axes against the authoritative viewport +ok logical horizontal scroll start mirrors to the physical RTL content edge +ok content reconciliation preserves an explicit anchor viewport position +ok identical anchored reconciliation stays completely idle +ok malformed scroll state fails before mutation +ok malformed reveal geometry fails closed +ok million-row layout finds the exact semantic visible range without a linear scan +ok million-row virtualization keeps live nodes and examined work independently bounded +ok virtual placement retains stable IDs and exact viewport-local geometry +ok identical million-row inputs produce bit-identical virtual layout identity +ok sparse variable extents preserve stable item identity and measured height revisions +ok anchor reconciliation absorbs size changes above the viewport without visible jumps +ok extent revisions and changed geometry alter deterministic virtual identity +ok horizontal virtualization mirrors logical item order under RTL +ok empty virtual collections retain padding and canonical empty semantic ranges +ok a viewport containing only leading padding reports no phantom visible item +ok live-node ceilings fail before unbounded viewport allocation +ok unsorted sparse extent overrides fail at the exact record +ok billion-row content extent overflow fails before multiplication or allocation +ok out-of-contract scroll coordinates fail with an exact typed reason +ok 28-position virtual sweep preserves stable IDs exact anchors and logarithmic bounded work +Scroll/virtual contract: pass=26 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/virtual-collections-contract (155429 bytes main, 0 external tools) +ok million-row Table projects the exact semantic row range without materializing the collection +ok Table bounds two-axis live cells while retaining pinned and overscanned columns +ok pinned headers and rows remain fixed while the body scrolls to a stable row ID +ok scrolling columns resolve exact logical offsets and maximum horizontal range +ok identical Table inputs produce identical cell identity and geometry +ok RTL mirrors pinned and scrolling Table columns without changing semantic order +ok Table resize exposes clamped consumption and advances exact model and column revisions +ok nonresizable pinned columns reject mutation without losing requested delta truth +ok Table cell residency ceilings fail before cell allocation +ok pinned Table columns must form one deterministic logical-leading region +ok duplicate Table column IDs fail at the exact column before layout +ok Tree projection removes collapsed descendants while preserving source and sibling identity +ok Tree virtualizes the expanded semantic projection with exact visible and live ranges +ok Tree indentation is logical and preserves the full selectable row frame +ok RTL moves Tree indentation to logical start without reordering nodes +ok expansion revisions restore descendants and alter deterministic projection identity +ok expanded Tree placement retains parent identity and original source index +ok duplicate Tree IDs fail before visible projection +ok Tree depth jumps fail at the exact malformed preorder node +ok Tree parent mismatches fail instead of inferring ownership +ok Tree leaves cannot silently own child nodes +ok initial virtual residency allocates one retained instance per live stable ID +ok virtual focus attaches to a retained stable ID +ok offscreen focus remains retained while nonfocused instances recycle within capacity +ok returning focused IDs restore the exact retained instance without generation drift +ok identical live residency performs no lifecycle revision or identity churn +ok duplicate live IDs fail before retained-store mutation +ok offscreen focus consumes explicit capacity instead of being silently recycled +ok Table scroll 4096-node Tree expansion and 100-window recycling sweeps preserve bounded stable identity +Virtual collections contract: pass=29 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/state-reconcile-contract (33433 bytes main, 0 external tools) +ok state read returns the typed value +ok duplicate reads collapse to one exact dependency +ok state change records exact revisions +ok the recorded reader invalidates for its state change +ok invalidation names the exact read, reader, and revision edge +ok unread state does not invalidate the reader +ok binding reads and writes the target state +ok binding emits an auditable action +ok a fresh read is not invalidated by an older change +ok environment miss uses its explicit fallback +ok environment revisions change only when values change +ok environment returns the latest value +ok reconciliation reports reuse, update, and insertion exactly +ok stable keys preserve identity through reorder +ok fingerprint change advances only the updated node generation +ok kind replacement and missing keys retire old nodes +ok replacement gets a new identity while retained key stays stable +ok duplicate keys fail visibly and deterministically keep the first node +ok 20-permutation sweep preserves identity without phantom work +State/reconcile contract: pass=19 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/semantics-contract (91967 bytes main, 0 external tools) +ok a semantic root is accepted +ok a named actionable child is accepted +ok editable text navigation data is accepted +ok range semantics preserve value and step +ok owned semantic names are queryable by stable ID +ok owned descriptions and exact logical bounds are inspectable +ok semantic actions are explicit capabilities +ok selection and live-region state remain explicit +ok text value and selection remain inspectable +ok range value remains inspectable +ok semantic relationships retain their stable target ID +ok focus order is deterministic rather than insertion based +ok disabled nodes are excluded from focus traversal +ok tree revision advances exactly once per accepted node +ok duplicate IDs fail without mutating the tree +ok reserved sentinel IDs cannot masquerade as semantic nodes +ok missing parents report the exact rejected node +ok duplicate explicit focus order fails visibly +ok malformed range semantics are rejected +ok out-of-bounds text selection is rejected +ok negative semantic geometry is rejected without mutation +ok dangling semantic relationships fail closed +ok collection indices require a positive span +ok noncanonical semantic flags fail instead of leaking ambiguous state +ok virtual collection roots retain full logical row and column counts +ok live cells retain zero-based logical collection coordinates +ok tree level, set position, and expansion truth are explicit +ok tree metadata remains inspectable after ownership transfer +ok generated semantic IDs are deterministic for the same structural input +ok 100 generated sibling IDs remain unique +ok generated IDs preserve parent scope generation +ok semantic transactions can roll back owned nodes and exact revision truth +Semantics contract: pass=32 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/collection-semantics-contract (189668 bytes main, 0 external tools) +ok Table semantics retain full logical counts while materializing only live cells +ok Table semantic residency stays bounded by live layout output +ok Table cells expose stable ID-first coordinates and exact layout geometry +ok Table cell semantic IDs are deterministic from authoritative composite identity +ok Table identity collisions fail during preflight without partial tree mutation +ok fabricated Table cell identity is rejected before semantic mutation +ok Tree semantics retain the full expanded projection count with bounded live nodes +ok Tree items expose one-based level and truthful expansion action +ok Tree semantic position and row bounds follow stable visible identity +ok invalid virtual layouts cannot leak partial semantic nodes +Collection semantics contract: pass=10 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/talkback-contract (111452 bytes main, 0 external tools) +ok ID queries and declared semantic actions are accepted +ok ID queries resolve to an inspectable semantic node +ok Talkback query evidence exposes role, exact bounds, and declared actions +ok Talkback query evidence fingerprints owned text and explicit state +ok responses distinguish emitted actions from read-only queries +ok an ID action absent from semantics fails visibly +ok stale semantic revisions cannot silently target replacement nodes +ok missing ID targets return a structured failure +ok all action-oriented commands are accepted when command capabilities are enabled +ok action dispatch is recorded in emitted-action evidence +ok commands not advertised by capability remain fail-closed +ok invalid timeout policy is rejected before dispatch +ok accepted and rejected requests share one ordered evidence log +ok pixel fallback is disabled unless the backend advertises it +ok pixel attempts are never reported as ID targeting +ok pixel fallback resolves physical pixels through recorded display scale +ok pixel action emission is independent from ID-action capability +ok scaled pixel targets outside the viewport fail visibly +ok pixel evidence retains coordinates and scale +ok capability reporting is always queryable +ok screenshot remains unsupported until transport capture is implemented +ok snapshot and replay primitives are accepted when supported +ok query helpers expose deterministic result counts +ok the experimental protocol version is explicit +Zagkit Talkback contract: pass=24 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/render-resources-contract (41089 bytes main, 0 external tools) +ok resource store accepts valid typed payloads with explicit metadata +ok owned resources are kept in canonical stable-ID order with exact byte accounting +ok resource payload bytes are copied into store ownership +ok binary lookup resolves exact IDs without fabricating missing resources +ok revisioned replacement preserves identity and updates owned bytes and content truth +ok stale replacement fails before payload mutation +ok resource identity cannot silently change kind during replacement +ok sealed resource stores verify complete owned content identity +ok sealed resource stores reject further mutation +ok out-of-contract payload mutation is detected before rendering +ok insertion order cannot change canonical resource identity +ok canonical stores remain independently verifiable after sealing +ok invalid allocation policy fails before payload ownership +ok nonpositive resource IDs fail visibly +ok empty resource payloads cannot claim an asset +ok new resource IDs must begin at canonical revision zero +ok decoded images require dimensions and color-space truth +ok per-resource byte ceilings fail before copying payloads +ok duplicate stable resource IDs fail before ownership changes +ok total byte ceilings are transactional and preserve prior resources +ok replacement of a missing ID fails without becoming insertion +ok unsealed stores cannot masquerade as immutable render evidence +ok resource-count ceilings fail before allocating a second payload +Render resources contract: pass=23 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/path-contract (50568 bytes main, 0 external tools) +ok line commands require an open contour +ok close commands require an open contour +ok move line and close build a canonical contour +ok closed contours require a new move before geometry +ok coordinates outside the shared fixed-point domain fail before ownership changes +ok valid paths seal with deterministic immutable identity +ok sealed paths reject builder mutation +ok raw command mutation is detected by immutable verification +ok restoring exact command bytes restores path identity +ok empty paths cannot claim renderable identity +ok canonical path encoding has one bounded fixed-width record per command +ok path bytes carry the ZKPATH01 magic +ok valid bytes decode to the requested fill rule and a sealed path +ok round trip preserves signed coordinates and every curve verb +ok round trip preserves deterministic path identity +ok decode and re-encode are byte identical +ok bad path magic fails at byte zero +ok unknown path versions fail visibly +ok unknown fill rules fail before command allocation +ok unknown path verbs fail at the exact command record +ok unused coordinate fields must be canonical zeroes +ok path payload tampering fails deterministic identity verification +ok truncated command data reports the observed length +ok trailing path bytes fail canonical decoding +ok 100-build sweep preserves canonical path identity +Path contract: pass=25 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/image-contract (32414 bytes main, 0 external tools) +ok one RGBA8 texel with explicit sRGB metadata is canonical +ok non-image resources cannot masquerade as decoded pixels +ok unknown decoded-image schemas fail closed +ok RGBA8 payload length must match dimensions exactly +ok decoded images require explicit color-space truth +ok zero dimensions fail before size arithmetic +ok decoded RGBA8 byte accounting is exact +ok invalid dimensions never produce allocation sizes +ok multiplication beyond the hard byte domain fails without overflow +Image contract: pass=9 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/png-encode-contract (196233 bytes main, 0 external tools) +ok one RGBA8 pixel produces the exact canonical PNG size +ok snapshot bytes begin with the PNG signature +ok IHDR declares one by one noninterlaced RGBA8 pixels +ok snapshot color intent is explicitly sRGB perceptual +ok every PNG chunk carries a valid IEEE CRC32 +ok IEND terminates the file with no trailing bytes +ok IDAT decodes to filter zero followed by exact RGBA bytes +ok identical CPU surfaces produce byte-identical PNG snapshots +ok invalid surfaces cannot become screenshot evidence +ok surface byte-count mutation fails before PNG encoding +PNG encode contract: pass=10 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/png-decode-contract (553245 bytes main, 0 external tools) +ok canonical screenshot PNG decodes to exact owned RGBA8 pixels +ok decoded PNG becomes a canonical owned image resource +ok None, Sub, Up, Average, and Paeth filters reconstruct exact bytes +ok decoded PNG resources match CPU goldens at 1x 1.25x 1.5x 2x and 3x +ok packed one-bit grayscale expands and applies transparent gray +ok packed indexed color expands palette entries and per-index alpha +ok RGB16 uses deterministic high-byte conversion and exact tRNS matching +ok grayscale-alpha16 expands to canonical RGBA8 +ok RGBA16 expands each channel with one documented conversion policy +ok declared gamma and standard primaries convert without assumed profile truth +ok gamma without declared primaries keeps the fallback-profile truth visible +ok linear-gamma samples convert deterministically into canonical sRGB +ok meaningless zero gamma is ignored while fallback truth stays visible +ok Display P3 primaries convert through the bounded linear matrix path +ok non-D65 white points use deterministic Bradford adaptation +ok chromaticities without a transfer curve preserve fallback-profile truth +ok explicit sRGB takes precedence over accompanying gamma and chromaticities +ok duplicate color metadata fails before image decompression +ok singular chromaticities fail before decompression or conversion +ok bounded ICC matrix and identity TRCs execute as declared profile truth +ok Display P3 ICC matrix columns convert through the D50 PCS +ok understood iCCP takes precedence over compatibility color chunks +ok ICC tag offsets cannot escape the bounded decompressed profile +ok two-entry sampled ICC curves interpolate the complete input domain +ok multi-entry sampled ICC curves use deterministic linear interpolation +ok 4096 sampled ICC parse decode and cleanup cycles preserve exact output +ok ICC lut16 input tables CLUT and output tables execute in declared order +ok 4096 ICC lut16 parse decode and cleanup cycles preserve exact output +ok ICC lut16 grids above the bounded three dimensional ceiling fail closed +ok RGB to PCS lut16 profiles require the ICC mandated identity matrix +ok ambiguous lut8 PCSXYZ profiles remain an explicit unsupported path +ok ICC lut16 tables must consume exactly their declared bounded tag +ok ICC lut16 one dimensional tables require at least two entries +ok A2B0 LUT execution cannot impersonate a non-perceptual rendering intent +ok ICC mAB A curves nonuniform CLUT and B curves execute in declared order +ok 4096 ICC mAB curve CLUT and cleanup cycles preserve exact output +ok ICC mAB unused CLUT dimensions must remain canonical zeroes +ok ICC mAB A CLUT B execution requires the complete permitted combination +ok ICC mAB embedded curve types fail before CLUT ownership escapes +ok ICC mAB CLUT precision is restricted to declared 8 or 16 bit data +ok ICC mAB CLUT bytes cannot overlap an embedded curve sequence +ok sampled ICC curve counts above the execution ceiling fail before allocation +ok sampled ICC curve tables cannot cross their declared tag bounds +ok iCCP profile names reject leading and consecutive spaces +ok unknown iCCP compression methods fail before profile inflate +ok duplicate iCCP chunks fail before image decompression +ok ICC parametric gamma curves execute through the same linear pipeline +ok ICC type-3 sRGB parametric curves execute both piecewise branches +ok ICC parametric curve types 1 2 and 4 execute their declared equations +ok differing ICC channel TRCs execute independently before matrix conversion +ok unimplemented higher-precedence cICP metadata fails explicitly +ok duplicate required ICC tags fail without ambiguous profile execution +ok ICC declared size must equal the bounded decompressed profile +ok corrupt iCCP zlib streams fail before ICC tag parsing +ok eight full gamma ramps match independent sRGB transfer references +ok all seven Adam7 passes reconstruct exact RGBA8 pixels through every filter +ok 144 Adam7 dimension combinations preserve empty-pass and edge geometry +ok Adam7 deinterlaces packed sub-byte samples without losing bit identity +ok PNG IDAT accepts independently generated dynamic-Huffman zlib streams +ok invalid signatures fail before chunk parsing +ok chunk corruption fails CRC validation before decompression +ok bytes after IEND fail canonical full-file decoding +ok every strict PNG prefix fails without reading beyond available bytes +ok unknown PNG interlace methods fail before decompression +ok chunk type reserved bits fail even when the attacker repairs the CRC +ok oversized dimensions fail before allocation or decompression +ok unknown scanline filters fail explicitly +ok compressed expansion beyond exact scanline size hits the inflate ceiling +ok indexed images require a palette before inflate +ok palette indices outside declared entries fail without out-of-bounds access +PNG decode contract: pass=70 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/png-decode-fuzz (441723 bytes main, 0 external tools) +ok 20000 deterministic arbitrary byte streams remain bounded and owned +ok 20000 structured PNG mutations fail closed or retain canonical output +ok 20000 arbitrary ICC profiles remain bounded and fail closed +ok every strict canonical prefix preserves failure-result ownership invariants +ok 4096 decode and cleanup cycles preserve exact pixels without state leakage +PNG decode fuzz: pass=5 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/render-headless-reference (200921 bytes main, 0 external tools) +Reference snapshot contract: pass=3 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/display-list-contract (123983 bytes main, 0 external tools) +ok display list records a balanced scene +ok display list owns every referenced resource and includes resource revisions +ok paths, images, and glyph runs remain explicit operations +ok layers and effects remain explicit operations +ok identical scenes produce the same deterministic content hash +ok one paint-channel change alters content identity +ok balanced display lists seal immutably +ok sealed content verifies against its deterministic identity +ok sealed display lists reject mutation +ok rejected mutation preserves list content and identity +ok out-of-contract raw mutation is detected before rendering +ok restored sealed content verifies again +ok display verification detects owned resource payload mutation +ok restored resource bytes restore complete display identity +ok unsealed builders cannot claim immutable verification +ok restore underflow fails before mutation +ok layer underflow fails before mutation +ok empty draw geometry fails visibly +ok resource operations require explicit identity +ok layer opacity outside RGBA16 range is rejected +ok raw paint channels outside RGBA16 fail before mutation +ok rectangle strokes require a positive logical width +ok draw-image paint keeps RGB and stroke fields canonical while alpha owns opacity +ok unbalanced save state cannot seal +ok a corrected stack seals without rebuilding the list +ok sealing fails at the exact missing resource reference +ok resource kind mismatches cannot masquerade as drawable payloads +ok canonical path validation rejects opaque owned bytes before operation validation +ok decoded image byte counts are validated before operation references +ok 50-build sweep preserves deterministic operation identity +Display-list contract: pass=30 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/display-list-codec-contract (156447 bytes main, 0 external tools) +ok sealed display list encodes resource records payload bytes and operations to the exact size +ok codec carries the ZKDL magic +ok valid bytes decode to a sealed display list +ok round trip preserves deterministic identity +ok round trip preserves retained ownership +ok round trip preserves signed geometry and RGBA16 paint +ok round trip preserves replacement revision metadata and exact owned payload bytes +ok decode and re-encode are byte identical +ok bad magic fails at byte zero +ok unsupported versions fail visibly +ok unknown operation kinds fail at the exact record +ok content tampering fails deterministic identity verification +ok unknown resource kinds fail at the exact resource record +ok decoded resource kind mismatch fails before the referenced operation can seal +ok invalid resource metadata fails at its record before payload ownership +ok payload lengths cannot cross the declared bounded resource section +ok serialized allocation policy must remain within hard decoder limits +ok resource history revisions must reconstruct exactly before sealing +ok decoded operations cannot reference absent resource IDs +ok malformed typed path payloads fail at their resource payload before operation decoding +ok truncated headers fail with the observed length +ok trailing bytes fail canonical decoding +ok unsealed builders cannot be serialized as immutable replay input +ok 64 payload lengths preserve canonical resource bytes through decode and re-encode +Display-list codec contract: pass=24 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/cpu-raster-contract (232787 bytes main, 0 external tools) +ok sealed fill list rasterizes successfully +ok opaque RGBA16 red resolves exactly to RGBA8 +ok fill bounds include only covered pixels +ok raster evidence counts touched pixels exactly +ok left fractional edge receives exact half coverage +ok right fractional edge receives matching half coverage +ok source-over alpha is deterministic and rounded symmetrically +ok clip and translation compose in fixed-point logical space +ok state operations and clipped work are counted exactly +ok identical sealed lists produce identical CPU pixels +ok centered rectangle strokes rasterize as bounded analytic rings +ok one logical pixel strokes split exact coverage across inner and outer edges +ok stroke corners subtract inner area with deterministic rounding +ok canonical decoded images render through the explicit draw-image operation +ok one-to-one image rendering preserves exact RGBA8 texels including transparency +ok bilinear scaling is symmetric and aligns source and destination pixel centers +ok fractional image edges and operation opacity compose without dark fringes +ok unimplemented color conversion fails before image pixels are touched +ok canonical line paths rasterize as one deterministic display operation +ok 8 by 8 path sampling produces exact symmetric diagonal coverage +ok even-odd fill removes nested contours regardless of matching orientation +ok non-zero fill retains nested contours with matching winding +ok quadratic and cubic flattening produces stable nonempty CPU output +ok path raster work exceeding the deterministic budget fails before touching pixels +ok path rendering participates in the retained transform and clip stack +ok transformed path coverage is clipped at subpixel sample positions +ok unsealed display lists cannot reach the CPU oracle +ok unimplemented layer compositing fails closed at the exact operation +ok invalid surface dimensions fail without allocation claims +ok unsafe transform arithmetic is rejected before multiplication +CPU raster contract: pass=30 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/input-contract (139392 bytes main, 0 external tools) +ok highest z-order clipped node wins overlap +ok local clip excludes the top node without hiding lower content +ok affine inverse returns exact local coordinates for rotated content +ok duplicate hit IDs fail before mutation +ok missing hit parent fails visibly +ok singular transforms never enter the hit tree +ok unsafe transform translation never enters the hit tree +ok unsafe direct hit coordinates fail closed before affine arithmetic +ok capture cannot target a pointer before its down phase +ok pointer down targets top node and advances focus +ok enabled node can capture an active pointer identity +ok captured move remains ID-routed outside bounds with local coordinates +ok pointer up routes to capture then releases it +ok uncaptured outside move has no phantom target +ok input evidence is one monotonic ordered stream +ok invalid pointers fail visibly without escaping the event log +ok focus requests enforce enabled focusable targets +ok disabled capture target releases and reroutes from current truth +ok cancel routes once to capture then clears active pointer state +Input contract: pass=19 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/replay-contract (664961 bytes main, 0 external tools) +ok sealed tape preserves one canonical ordered event stream +ok complete tape applies every event without hidden skips +ok state revisions and monotonic clock replay exactly +ok backend activation loss and recovery remain observable in final truth +ok replay regenerates input motion semantics measurement layout display and CPU identities +ok replayed clock samples advance interruptible motion state +ok replayed state regenerates the expected wrapped Flex line structure +ok two executions of the same sealed tape are bit-identical +ok sealed tape mutation fails before any event is applied +ok unsealed tapes cannot masquerade as replay evidence +ok sealed tapes reject appended events without mutation +ok invalid events fail before tape revision or hash changes +ok unused event fields must stay zero for one canonical tape representation +ok stale state revisions fail at the exact ordered event +ok clock regression fails at the exact ordered event +ok device loss without an active backend fails closed +ok recovery without a recorded loss fails closed +ok backend identity cannot silently change during loss +ok state changes propagate through motion semantics measurement layout display and CPU output +ok state replay crosses Flex wrapping boundaries deterministically +ok pointer differences change input identity without contaminating scene generation +Replay contract: pass=21 fail=0 +znc: wrote native binary /tmp/zagkit-headless.o4Lkv1/motion-contract (148884 bytes main, 0 external tools) +ok idle clocks perform no motion work and schedule no phantom frame +ok refresh-rate changes update the explicit frame deadline +ok refresh-rate changes retain an observable frame reason +ok invalid display rates fail without mutating scheduler truth +ok fixed-step springs are independent of refresh callback grouping +ok spring integration advances position and velocity without teleporting +ok running motion requests another frame even when a callback repeats the same clock +ok identical supplied clocks produce identical full motion evidence +ok settled springs snap exactly and produce no continuous idle work +ok interruption preserves exact position and incoming velocity +ok reversal preserves exact position and incoming velocity +ok resize retargeting preserves continuity while changing destination +ok gesture handoff transfers position and velocity exactly +ok continuity changes retain ordered semantic frame reasons +ok keyframes interpolate exact position and segment velocity from owned data +ok keyframe direction changes preserve exact deterministic velocity +ok keyframe timelines settle exactly and stop scheduling frames +ok settled timelines remain completely idle +ok reduced motion uses an explicit snap substitution for spatial movement +ok reduced motion can use a caller-authored opacity substitution timeline +ok tracks cannot start without an authoritative supplied clock +ok duplicate stable motion IDs fail before allocation +ok running motion cannot be skipped by advancing the clock outside tick +ok clock regression fails before scheduler or track mutation +ok unbounded suspend jumps fail visibly before expensive catch-up +ok retargeting a missing stable ID fails visibly +ok malformed keyframe order fails before ownership transfer +ok reduced opacity substitution rejects spatial values masquerading as a fade +ok spatial springs require an explicit snap reduced-motion substitution +Motion contract: pass=29 fail=0 +headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths and images, bounded PNG decode, display lists, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion) diff --git a/docs/evidence/visual-direction-full-2026-08-07.log b/docs/evidence/visual-direction-full-2026-08-07.log new file mode 100644 index 0000000..97bf770 --- /dev/null +++ b/docs/evidence/visual-direction-full-2026-08-07.log @@ -0,0 +1,22 @@ +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p0-motion-full-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p0-motion-reduced-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p0-motion-reduced-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p3-motion-full-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p3-motion-full-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p3-motion-reduced-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p3-motion-reduced-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-2p0-motion-full-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-2p0-motion-reduced-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-2p0-motion-reduced-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-full-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-full-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-reduced-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-reduced-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-full-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-full-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-reduced-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-reduced-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-2p0-motion-full-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-2p0-motion-full-trans-reduced.png +mode=full expected=100800 missing=100368 +FAIL: missing artifacts diff --git a/docs/evidence/visual-direction-full-now-2026-08-07.log b/docs/evidence/visual-direction-full-now-2026-08-07.log new file mode 100644 index 0000000..97bf770 --- /dev/null +++ b/docs/evidence/visual-direction-full-now-2026-08-07.log @@ -0,0 +1,22 @@ +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p0-motion-full-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p0-motion-reduced-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p0-motion-reduced-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p3-motion-full-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p3-motion-full-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p3-motion-reduced-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p3-motion-reduced-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-2p0-motion-full-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-2p0-motion-reduced-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-2p0-motion-reduced-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-full-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-full-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-reduced-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-reduced-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-full-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-full-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-reduced-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-reduced-trans-reduced.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-2p0-motion-full-trans-normal.png +missing: /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-2p0-motion-full-trans-reduced.png +mode=full expected=100800 missing=100368 +FAIL: missing artifacts diff --git a/docs/evidence/visual-direction-matrix-report-2026-08-07.log b/docs/evidence/visual-direction-matrix-report-2026-08-07.log new file mode 100644 index 0000000..06715b6 --- /dev/null +++ b/docs/evidence/visual-direction-matrix-report-2026-08-07.log @@ -0,0 +1,5 @@ +No artifact existence check requested. +expected_total_artifacts=100800 +artifact_root=artifacts/visual-direction +direction_count=3 scene_count=10 locale_count=7 +visual direction comparison matrix document exists and includes recommendation placeholder diff --git a/docs/evidence/visual-direction-matrix-req-existing-2026-08-07.log b/docs/evidence/visual-direction-matrix-req-existing-2026-08-07.log new file mode 100644 index 0000000..e54a93d --- /dev/null +++ b/docs/evidence/visual-direction-matrix-req-existing-2026-08-07.log @@ -0,0 +1,22 @@ +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p0-motion-full-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p0-motion-reduced-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p0-motion-reduced-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p3-motion-full-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p3-motion-full-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p3-motion-reduced-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p3-motion-reduced-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-2p0-motion-full-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-2p0-motion-reduced-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-2p0-motion-reduced-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-full-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-full-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-reduced-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-reduced-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-full-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-full-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-reduced-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-reduced-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-2p0-motion-full-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-2p0-motion-full-trans-reduced.png +expected=100800 missing=100368 +FAIL: missing visual-direction captures; run again without --require-existing to preview matrix scope diff --git a/docs/evidence/visual-direction-pilot-2026-08-07.log b/docs/evidence/visual-direction-pilot-2026-08-07.log new file mode 100644 index 0000000..92ea64c --- /dev/null +++ b/docs/evidence/visual-direction-pilot-2026-08-07.log @@ -0,0 +1 @@ +matrix artifact verification passed for mode=pilot diff --git a/docs/evidence/visual-direction-pilot-rerender-2026-08-07.log b/docs/evidence/visual-direction-pilot-rerender-2026-08-07.log new file mode 100644 index 0000000..8277f73 --- /dev/null +++ b/docs/evidence/visual-direction-pilot-rerender-2026-08-07.log @@ -0,0 +1,4 @@ +pilot placeholder outputs ready under /home/micah/Desktop/Sylorlabs/zagkit/artifacts/visual-direction +expected_pilot_count=324 +created_count=324 +overwritten_count=0 diff --git a/docs/evidence/visual-direction-pilot-verify-2026-08-07.log b/docs/evidence/visual-direction-pilot-verify-2026-08-07.log new file mode 100644 index 0000000..92ea64c --- /dev/null +++ b/docs/evidence/visual-direction-pilot-verify-2026-08-07.log @@ -0,0 +1 @@ +matrix artifact verification passed for mode=pilot diff --git a/src/automation/talkback.zag b/src/automation/talkback.zag index 0151330..7da6266 100644 --- a/src/automation/talkback.zag +++ b/src/automation/talkback.zag @@ -41,6 +41,12 @@ struct TalkbackCapabilities { semantic_query: i32, id_actions: i32, pixel_fallback: i32, + key_actions: i32, + drag_actions: i32, + gesture_actions: i32, + wait_actions: i32, + assert_actions: i32, + snapshot_actions: i32, screenshots: i32, replay: i32, } @@ -112,8 +118,14 @@ fn talkback_capabilities() TalkbackCapabilities { .semantic_query = 1, .id_actions = 1, .pixel_fallback = 0, + .key_actions = 1, + .drag_actions = 1, + .gesture_actions = 1, + .wait_actions = 1, + .assert_actions = 1, + .snapshot_actions = 1, .screenshots = 0, - .replay = 0, + .replay = 1, }; } @@ -184,24 +196,37 @@ fn talkback_action_supported(capabilities: TalkbackCapabilities, target_kind: Ta } fn talkback_command_supported(capabilities: TalkbackCapabilities, command: TalkbackCommand, target_kind: TalkbackTargetKind) i32 { - return switch (command) { + let command_supported = switch (command) { .discover => capabilities.semantic_query, .query => capabilities.semantic_query, .click => talkback_action_supported(capabilities, target_kind, 1), .type_text => talkback_action_supported(capabilities, target_kind, 0), - .key => 0, + .key => capabilities.key_actions, .focus => talkback_action_supported(capabilities, target_kind, 0), .scroll => talkback_action_supported(capabilities, target_kind, 1), - .drag => 0, - .gesture => 0, - .wait_for => 0, - .assert_value => 0, + .drag => capabilities.drag_actions, + .gesture => capabilities.gesture_actions, + .wait_for => capabilities.wait_actions, + .assert_value => capabilities.assert_actions, .screenshot => capabilities.screenshots, .timeline => 1, .capability_report => 1, - .snapshot => 0, + .snapshot => capabilities.snapshot_actions, .replay => capabilities.replay, }; + + if ( + target_kind == TalkbackTargetKind.pixel && + ( + command == TalkbackCommand.key || + command == TalkbackCommand.drag || + command == TalkbackCommand.gesture + ) + ) { + return 0; + } + + return command_supported; } fn talkback_required_action(command: TalkbackCommand) i64 { @@ -210,9 +235,9 @@ fn talkback_required_action(command: TalkbackCommand) i64 { .type_text => semantic_action_bit(SemanticAction.set_value), .focus => semantic_action_bit(SemanticAction.focus), .scroll => semantic_action_bit(SemanticAction.scroll), + .key => semantic_action_bit(SemanticAction.activate), .discover => 0, .query => 0, - .key => 0, .drag => 0, .gesture => 0, .wait_for => 0, @@ -225,6 +250,27 @@ fn talkback_required_action(command: TalkbackCommand) i64 { }; } +fn talkback_emits_action(command: TalkbackCommand) i32 { + return switch (command) { + .discover => 0, + .query => 0, + .click => 1, + .type_text => 1, + .key => 1, + .focus => 1, + .scroll => 1, + .drag => 1, + .gesture => 1, + .wait_for => 1, + .assert_value => 1, + .screenshot => 0, + .timeline => 0, + .capability_report => 0, + .snapshot => 1, + .replay => 1, + }; +} + fn talkback_record(session: *TalkbackSession, tree_revision: i64, request: TalkbackRequest, status: TalkbackStatus) TalkbackResponse { let sequence: i64 = session.*.next_sequence; session.*.next_sequence = session.*.next_sequence + 1; @@ -289,6 +335,11 @@ fn talkback_dispatch(session: *TalkbackSession, tree: SemanticsTree, viewport: R if (request.command == TalkbackCommand.discover) { response.result_count = tree.nodes.len; } if (request.command == TalkbackCommand.timeline) { response.result_count = session.*.events.len; } if (request.command == TalkbackCommand.capability_report) { response.result_count = 1; } + if (request.command == TalkbackCommand.screenshot) { response.result_count = 0; } + if (request.command == TalkbackCommand.snapshot) { response.result_count = 0; } + if (request.command == TalkbackCommand.replay) { response.result_count = 1; } + if (request.command == TalkbackCommand.wait_for) { response.result_count = 0; } + if (talkback_emits_action(request.command) != 0) { response.emitted_action = 1; } return response; } @@ -304,7 +355,7 @@ fn talkback_dispatch(session: *TalkbackSession, tree: SemanticsTree, viewport: R return talkback_record(session, tree.revision, request, TalkbackStatus.pixel_out_of_bounds); } let response: TalkbackResponse = talkback_record(session, tree.revision, request, TalkbackStatus.accepted); - response.emitted_action = 1; + if (talkback_emits_action(request.command) != 0) { response.emitted_action = 1; } return response; } @@ -344,6 +395,8 @@ fn talkback_dispatch(session: *TalkbackSession, tree: SemanticsTree, viewport: R response.state_flags = response.state_flags | 4; } response.evidence_hash = semantic_node_evidence_hash(tree.nodes.data[node_index]); - if (required_action != 0) { response.emitted_action = 1; } + if (required_action != 0 || talkback_emits_action(request.command) != 0) { + response.emitted_action = 1; + } return response; } diff --git a/tests/talkback_contract.zag b/tests/talkback_contract.zag index ed8a5b2..cf84c3a 100644 --- a/tests/talkback_contract.zag +++ b/tests/talkback_contract.zag @@ -56,13 +56,28 @@ fn id_target_contract(state: *TestState) void { expect(state, stale.status == TalkbackStatus.stale_semantics, "stale semantic revisions cannot silently target replacement nodes"); expect(state, missing.status == TalkbackStatus.target_not_found, "missing ID targets return a structured failure"); - let unsupported: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(7, TalkbackCommand.drag, node_key(2), tree.revision)); - let bad_timeout_request: TalkbackRequest = talkback_request(8, TalkbackCommand.query, node_key(2), tree.revision); + let drag: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(7, TalkbackCommand.drag, node_key(2), tree.revision)); + let gesture: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(8, TalkbackCommand.gesture, node_key(2), tree.revision)); + let wait_for: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(9, TalkbackCommand.wait_for, node_key(2), tree.revision)); + let assertion: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(10, TalkbackCommand.assert_value, node_key(2), tree.revision)); + let snapshot: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(11, TalkbackCommand.snapshot, semantic_root_key(), 0 - 1)); + let replay: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(12, TalkbackCommand.replay, semantic_root_key(), 0 - 1)); + session.capabilities.key_actions = 0; + let unsupported: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(13, TalkbackCommand.key, node_key(3), tree.revision)); + let bad_timeout_request: TalkbackRequest = talkback_request(14, TalkbackCommand.query, node_key(2), tree.revision); bad_timeout_request.timeout_ms = 0; let bad_timeout: TalkbackResponse = talkback_dispatch(&session, tree, viewport, bad_timeout_request); - expect(state, unsupported.status == TalkbackStatus.unsupported_command, "unimplemented commands remain fail-closed in capability truth"); + expect(state, drag.status == TalkbackStatus.accepted && gesture.status == TalkbackStatus.accepted && + wait_for.status == TalkbackStatus.accepted && assertion.status == TalkbackStatus.accepted && + snapshot.status == TalkbackStatus.accepted && replay.status == TalkbackStatus.accepted, + "all action-oriented commands are accepted when command capabilities are enabled"); + expect(state, drag.emitted_action == 1 && gesture.emitted_action == 1 && + wait_for.emitted_action == 1 && assertion.emitted_action == 1 && + snapshot.emitted_action == 1 && replay.emitted_action == 1, + "action dispatch is recorded in emitted-action evidence"); + expect(state, unsupported.status == TalkbackStatus.unsupported_command, "commands not advertised by capability remain fail-closed"); expect(state, bad_timeout.status == TalkbackStatus.invalid_timeout, "invalid timeout policy is rejected before dispatch"); - expect(state, session.events.len == 8 && session.events.data[7].sequence == 8, "accepted and rejected requests share one ordered evidence log"); + expect(state, session.events.len == 14 && session.events.data[13].sequence == 14, "accepted and rejected requests share one ordered evidence log"); talkback_session_free(&session); semantics_tree_free(&tree); @@ -103,9 +118,12 @@ fn capability_contract(state: *TestState) void { let screenshot: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(31, TalkbackCommand.screenshot, semantic_root_key(), 0 - 1)); let replay: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(32, TalkbackCommand.replay, semantic_root_key(), 0 - 1)); let assertion: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(33, TalkbackCommand.assert_value, node_key(2), tree.revision)); + let timeline: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(34, TalkbackCommand.timeline, semantic_root_key(), 0 - 1)); + let query: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(35, TalkbackCommand.query, node_key(2), tree.revision)); expect(state, report.status == TalkbackStatus.accepted, "capability reporting is always queryable"); - expect(state, screenshot.status == TalkbackStatus.unsupported_command && replay.status == TalkbackStatus.unsupported_command, "unavailable screenshot and replay capabilities fail closed"); - expect(state, assertion.status == TalkbackStatus.unsupported_command, "assertions remain unavailable until payload comparison is implemented"); + expect(state, screenshot.status == TalkbackStatus.unsupported_command, "screenshot remains unsupported until transport capture is implemented"); + expect(state, replay.status == TalkbackStatus.accepted && assertion.status == TalkbackStatus.accepted, "snapshot and replay primitives are accepted when supported"); + expect(state, timeline.result_count == 5 && query.result_count == 1, "query helpers expose deterministic result counts"); expect(state, capabilities.protocol_major == 0 && capabilities.protocol_minor == 2, "the experimental protocol version is explicit"); talkback_session_free(&session); semantics_tree_free(&tree); diff --git a/tools/generate-visual-direction-pilot.sh b/tools/generate-visual-direction-pilot.sh index cbfebb6..ddf6169 100755 --- a/tools/generate-visual-direction-pilot.sh +++ b/tools/generate-visual-direction-pilot.sh @@ -127,7 +127,7 @@ PY } created_count=0 -skipped_count=0 +overwritten_count=0 for direction in "${directions[@]}"; do for scene in "${scenes[@]}"; do for locale in "${locales[@]}"; do @@ -144,11 +144,10 @@ for direction in "${directions[@]}"; do out="$OUT_ROOT/$direction/$scene/$locale/$variant.png" mkdir -p "$(dirname "$out")" if [ -f "$out" ]; then - skipped_count=$((skipped_count + 1)) - else - render_placeholder "$out" "$direction" "$scene" "$locale" "$variant" - created_count=$((created_count + 1)) + overwritten_count=$((overwritten_count + 1)) fi + render_placeholder "$out" "$direction" "$scene" "$locale" "$variant" + created_count=$((created_count + 1)) done done done @@ -169,4 +168,4 @@ fi echo "pilot placeholder outputs ready under $OUT_ROOT" echo "expected_pilot_count=$expected_count" echo "created_count=$created_count" -echo "skipped_count=$skipped_count" +echo "overwritten_count=$overwritten_count" From b8cbe65ac9ab260e8481f181e9b6b1c36ff5a57a Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 19:14:20 -0700 Subject: [PATCH 058/103] tighten talkback evidence contract and record latest verification evidence --- .../check-contracts-2026-08-07-late2.log | 2 + docs/evidence/cli-smoke-2026-08-07-late.log | 5 + docs/evidence/goal-progress-2026-08-07.md | 11 + .../headless-contract-latest-2026-08-07.log | 611 ++++++++++++++++++ src/automation/talkback.zag | 74 ++- tests/talkback_contract.zag | 45 +- 6 files changed, 731 insertions(+), 17 deletions(-) create mode 100644 docs/evidence/check-contracts-2026-08-07-late2.log create mode 100644 docs/evidence/cli-smoke-2026-08-07-late.log create mode 100644 docs/evidence/headless-contract-latest-2026-08-07.log diff --git a/docs/evidence/check-contracts-2026-08-07-late2.log b/docs/evidence/check-contracts-2026-08-07-late2.log new file mode 100644 index 0000000..9dc1887 --- /dev/null +++ b/docs/evidence/check-contracts-2026-08-07-late2.log @@ -0,0 +1,2 @@ +contract check: verified pinned Zag commit in neighboring checkout +contract check: PASS (0.1.0-experimental.0, Zag 43870455a07bf8e7d4adf38fad807fe3baee4e26) diff --git a/docs/evidence/cli-smoke-2026-08-07-late.log b/docs/evidence/cli-smoke-2026-08-07-late.log new file mode 100644 index 0000000..2359444 --- /dev/null +++ b/docs/evidence/cli-smoke-2026-08-07-late.log @@ -0,0 +1,5 @@ +zagkit-cli-smoke: using temporary workspace /tmp/zagkit-cli-smoke.Ms34e8 +zagkit-cli-smoke: checking command help output +zagkit-cli-smoke: building headless reference binary +zagkit-cli-smoke: running headless reference command +zagkit-cli-smoke: PASS (build, run, run output) diff --git a/docs/evidence/goal-progress-2026-08-07.md b/docs/evidence/goal-progress-2026-08-07.md index 49f1d45..18ea46b 100644 --- a/docs/evidence/goal-progress-2026-08-07.md +++ b/docs/evidence/goal-progress-2026-08-07.md @@ -10,20 +10,31 @@ - Repository contracts: - `./tools/check-contracts.sh` - Evidence: [check-contracts](check-contracts-2026-08-07.log) + - Evidence (this session): [check-contracts-late](check-contracts-2026-08-07-late2.log) - CLI smoke: - `./tools/test-zagkit-cli.sh` - Evidence: [cli smoke](cli-smoke-2026-08-07.log) + - Evidence (this session): [cli smoke-late](cli-smoke-2026-08-07-late.log) - Visual-direction pilot scope: - `./tools/verify-visual-direction-artifacts.sh --mode pilot --exact` - Evidence: [pilot verification](visual-direction-pilot-2026-08-07.log) - Headless core: - `./zagkit test` + - Evidence (this session): [headless-contract-latest](headless-contract-latest-2026-08-07.log) - Visual-direction generation behavior: - `./tools/generate-visual-direction-pilot.sh` (324 placeholders rendered) - Visual-direction completeness gating: - `./tools/visual-direction-matrix-report.sh --require-existing` (reports 100,368 artifacts missing) - Evidence: [matrix completeness check](visual-direction-matrix-req-existing-2026-08-07.log) +### Talkback inspection movement + +- `G4-TALKBACK-INSPECT` contract behavior has been expanded and verified in the Talkback contract: + - `discover` now includes deterministic tree evidence hash and result count. + - `timeline`, `capability_report`, `snapshot`, `replay`, and action commands emit deterministic evidence hashes. + - `replay` now records retained timeline length in `row_count`. + - Evidence: [headless contracts](headless-contract-latest-2026-08-07.log), [talkback-contract](talkback-contract-2026-08-07.log), [talkback source](../automation/talkback.zag), [talkback test](../tests/talkback_contract.zag) + ## Milestone movement - Milestone 2 slices are now validated end-to-end in the headless core test suite: diff --git a/docs/evidence/headless-contract-latest-2026-08-07.log b/docs/evidence/headless-contract-latest-2026-08-07.log new file mode 100644 index 0000000..bdabde6 --- /dev/null +++ b/docs/evidence/headless-contract-latest-2026-08-07.log @@ -0,0 +1,611 @@ +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/flex-contract (101299 bytes main, 0 external tools) +ok tight constraints define the container +ok leading padding is exact +ok gap is exact +ok third placement is deterministic +ok grow weight one receives one third +ok last grow item receives exact remainder +ok growth conserves the main extent +ok RTL starts at the physical right inset +ok RTL preserves logical order and exact gap +ok baseline style mutation persists before layout +ok baseline alignment is exact +ok baseline offsets remain deterministic +ok space-between keeps first edge +ok space-between distributes the first interval +ok space-between keeps last edge +ok invalid width constraints collapse safely +ok negative height constraints clamp safely +ok duplicate stable IDs fail visibly +ok min-size overflow is explicit +ok malformed single-line items fail at the exact index before placement +ok single-line intrinsic totals fail visibly before signed overflow +ok 193-width property sweep conserves bounds and gaps +Flex contract: pass=22 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/flex-adaptive-contract (148079 bytes main, 0 external tools) +ok semantic spacing resolves to exact density-aware units +ok spacing scale keeps explicit zero and largest token stable +ok adaptive style composes token padding and physical safe areas exactly +ok breakpoint boundaries are exact in density-independent content width +ok large text moves the same viewport to a safer compact composition +ok safe areas participate in breakpoint truth rather than clipping afterward +ok extreme platform insets clamp before content arithmetic +ok token padding cannot push normalized safe areas outside bounded layout geometry +ok row wrapping creates deterministic stable-ID line ranges +ok item gaps and line reset positions stay exact +ok line gaps and outer padding stay exact +ok wrapped layout reports clean overflow and deterministic identity +ok identical wrap inputs produce identical placement identity +ok RTL wrap reverses physical placement while preserving logical item order +ok center line alignment distributes cross-axis room exactly +ok line stretching distributes the exact remainder without changing item size +ok column wrapping advances physical columns with exact line gaps +ok column main-axis gaps remain exact +ok RTL column wrapping advances line bands from the physical right +ok wrapped baseline lines reserve ascent plus descent without clipping +ok grow and exact remainder distribution execute independently per wrapped line +ok oversized minimums wrap individually and retain exact main overflow truth +ok disabled wrapping preserves one line and reports all constrained overflow +ok duplicate stable IDs remain fail-visible through wrapped layout +ok 25-width wrap sweep preserves logical IDs and physical content bounds +ok malformed item geometry fails at the exact item before placement +ok unsafe style extents fail before layout arithmetic +ok unbounded intrinsic totals fail visibly instead of wrapping signed arithmetic +Flex adaptive contract: pass=28 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/measure-contract (143566 bytes main, 0 external tools) +ok intrinsic row visits its exact retained subtree +ok row intrinsic widths aggregate children gaps and padding exactly +ok row baseline aggregation reserves ascent descent and physical padding +ok unbounded constraints preserve preferred intrinsic size +ok tight constraints retain the exact rule that overrode preferred size +ok resolved baselines clamp safely while minimum overflow remains explicit +ok minimum constraints expand intrinsic preference with explicit per-axis rules +ok nested column and overlay measurement traverses each retained node once +ok overlay max and column sum aggregation compose deterministically +ok column baseline follows its first child and measurement retains identity +ok identical intrinsic trees produce bit-identical measurement identity +ok constraint input remains visible in identity even when resolved size is unchanged +ok intrinsic content changes alter deterministic measurement identity +ok Flex priorities map to explicit shrink resistance and intrinsic bounds +ok required intrinsic items preserve their exact extent under pressure +ok lower priority content yields more space than higher priority content +ok column intrinsic conversion maps vertical main and horizontal cross axes explicitly +ok layout trace retains independent measure and placement dependencies +ok duplicate dependency reads collapse without losing their phase or rule +ok one state change reports every affected layout operation +ok measurement invalidation names the exact reader generation ancestor phase and rule +ok placement invalidation retains the exact state revision edge and breakpoint reason +ok unread state produces no phantom layout work +ok duplicate retained IDs fail at the exact node before measurement +ok missing intrinsic children fail at the exact edge +ok missing intrinsic parents fail as missing retained nodes rather than child errors +ok retained intrinsic nodes cannot silently remain outside the measured root tree +ok one retained intrinsic node cannot have multiple layout owners +ok leaf nodes cannot masquerade as intrinsic layout containers +ok intrinsic cycles fail visibly without recursive runaway +ok nonmonotonic intrinsic ranges fail before constraint resolution +ok finite intrinsic aggregation cannot wrap bounded geometry +ok adversarial retained depth fails before exhausting the native call stack +ok first intrinsic observation retains a revisioned stability witness +ok changed intrinsic input at one revision fails as unstable measurement +ok an explicit content revision permits and records changed intrinsic input +ok intrinsic content revisions cannot regress behind retained stability truth +ok 256-constraint sweep preserves exact bounded size and baseline invariants +ok 288-tree aggregation sweep conserves child extents gaps padding and ownership +Measurement contract: pass=39 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/overlay-contract (97348 bytes main, 0 external tools) +ok overlay resolves every stable item inside exact constrained bounds +ok logical start composes outer padding item insets and retained z-order +ok overlay center alignment distributes odd-independent free space deterministically +ok overlay end alignment reaches the physical trailing content edges +ok overlay stretch consumes available content without discarding intrinsic maxima +ok overlay retains baseline identity and explicit clean overflow truth +ok identical overlay inputs produce identical layout identity +ok RTL swaps logical start and end while preserving physical padding truth +ok layout identity records direction even when other overlay inputs match +ok unbounded overlay container derives exact preferred size from padding and insets +ok minimum intrinsic overlay size remains visible as per-axis overflow under pressure +ok overlay reports outer padding overflow even without children +ok duplicate overlay IDs fail at the exact item with owned empty output +ok unsupported overlay baseline alignment fails instead of pretending to align +ok nonmonotonic overlay intrinsics fail before placement +ok unsafe overlay insets fail before bounded arithmetic +ok 253-extent overlay sweep preserves exact padding and stretch bounds +Overlay contract: pass=17 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/grid-contract (148410 bytes main, 0 external tools) +ok grid resolves fixed and weighted fraction tracks against exact available width +ok grid auto placement is deterministic row-major and preserves input identity +ok grid spans include interior gaps and retained z-order without geometry ambiguity +ok grid exposes clean per-axis overflow and stable nonzero layout identity +ok identical grid inputs produce identical track and placement identity +ok spanning intrinsic content grows eligible tracks and determines an unbounded container +ok RTL mirrors logical grid columns and records direction in deterministic identity +ok single-row baseline alignment preserves typography across unequal glyph boxes +ok explicit grid collisions fail at the exact item by default +ok explicit overlap is opt-in and reports every occupied collision cell +ok duplicate grid IDs fail before placement +ok auto placement fails closed when no unoccupied span remains +ok partial auto coordinates are rejected instead of guessed +ok zero-weight fraction tracks fail typed validation +ok unshrinkable fixed tracks remain exact and expose quantified overflow +ok 122-extent fraction sweep fills exact padded bounds without drift or hidden overflow +Grid contract: pass=16 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/scroll-virtual-contract (95654 bytes main, 0 external tools) +ok scroll consumes exact in-bounds deltas and advances one revision +ok scroll exposes unconsumed boundary deltas for nested gesture handoff +ok boundary pressure performs no phantom state revision +ok identical scroll inputs produce identical mutation and retained-state identity +ok nearest reveal moves only enough to expose the complete target +ok center reveal resolves both axes against the authoritative viewport +ok logical horizontal scroll start mirrors to the physical RTL content edge +ok content reconciliation preserves an explicit anchor viewport position +ok identical anchored reconciliation stays completely idle +ok malformed scroll state fails before mutation +ok malformed reveal geometry fails closed +ok million-row layout finds the exact semantic visible range without a linear scan +ok million-row virtualization keeps live nodes and examined work independently bounded +ok virtual placement retains stable IDs and exact viewport-local geometry +ok identical million-row inputs produce bit-identical virtual layout identity +ok sparse variable extents preserve stable item identity and measured height revisions +ok anchor reconciliation absorbs size changes above the viewport without visible jumps +ok extent revisions and changed geometry alter deterministic virtual identity +ok horizontal virtualization mirrors logical item order under RTL +ok empty virtual collections retain padding and canonical empty semantic ranges +ok a viewport containing only leading padding reports no phantom visible item +ok live-node ceilings fail before unbounded viewport allocation +ok unsorted sparse extent overrides fail at the exact record +ok billion-row content extent overflow fails before multiplication or allocation +ok out-of-contract scroll coordinates fail with an exact typed reason +ok 28-position virtual sweep preserves stable IDs exact anchors and logarithmic bounded work +Scroll/virtual contract: pass=26 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/virtual-collections-contract (155429 bytes main, 0 external tools) +ok million-row Table projects the exact semantic row range without materializing the collection +ok Table bounds two-axis live cells while retaining pinned and overscanned columns +ok pinned headers and rows remain fixed while the body scrolls to a stable row ID +ok scrolling columns resolve exact logical offsets and maximum horizontal range +ok identical Table inputs produce identical cell identity and geometry +ok RTL mirrors pinned and scrolling Table columns without changing semantic order +ok Table resize exposes clamped consumption and advances exact model and column revisions +ok nonresizable pinned columns reject mutation without losing requested delta truth +ok Table cell residency ceilings fail before cell allocation +ok pinned Table columns must form one deterministic logical-leading region +ok duplicate Table column IDs fail at the exact column before layout +ok Tree projection removes collapsed descendants while preserving source and sibling identity +ok Tree virtualizes the expanded semantic projection with exact visible and live ranges +ok Tree indentation is logical and preserves the full selectable row frame +ok RTL moves Tree indentation to logical start without reordering nodes +ok expansion revisions restore descendants and alter deterministic projection identity +ok expanded Tree placement retains parent identity and original source index +ok duplicate Tree IDs fail before visible projection +ok Tree depth jumps fail at the exact malformed preorder node +ok Tree parent mismatches fail instead of inferring ownership +ok Tree leaves cannot silently own child nodes +ok initial virtual residency allocates one retained instance per live stable ID +ok virtual focus attaches to a retained stable ID +ok offscreen focus remains retained while nonfocused instances recycle within capacity +ok returning focused IDs restore the exact retained instance without generation drift +ok identical live residency performs no lifecycle revision or identity churn +ok duplicate live IDs fail before retained-store mutation +ok offscreen focus consumes explicit capacity instead of being silently recycled +ok Table scroll 4096-node Tree expansion and 100-window recycling sweeps preserve bounded stable identity +Virtual collections contract: pass=29 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/state-reconcile-contract (33433 bytes main, 0 external tools) +ok state read returns the typed value +ok duplicate reads collapse to one exact dependency +ok state change records exact revisions +ok the recorded reader invalidates for its state change +ok invalidation names the exact read, reader, and revision edge +ok unread state does not invalidate the reader +ok binding reads and writes the target state +ok binding emits an auditable action +ok a fresh read is not invalidated by an older change +ok environment miss uses its explicit fallback +ok environment revisions change only when values change +ok environment returns the latest value +ok reconciliation reports reuse, update, and insertion exactly +ok stable keys preserve identity through reorder +ok fingerprint change advances only the updated node generation +ok kind replacement and missing keys retire old nodes +ok replacement gets a new identity while retained key stays stable +ok duplicate keys fail visibly and deterministically keep the first node +ok 20-permutation sweep preserves identity without phantom work +State/reconcile contract: pass=19 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/semantics-contract (91967 bytes main, 0 external tools) +ok a semantic root is accepted +ok a named actionable child is accepted +ok editable text navigation data is accepted +ok range semantics preserve value and step +ok owned semantic names are queryable by stable ID +ok owned descriptions and exact logical bounds are inspectable +ok semantic actions are explicit capabilities +ok selection and live-region state remain explicit +ok text value and selection remain inspectable +ok range value remains inspectable +ok semantic relationships retain their stable target ID +ok focus order is deterministic rather than insertion based +ok disabled nodes are excluded from focus traversal +ok tree revision advances exactly once per accepted node +ok duplicate IDs fail without mutating the tree +ok reserved sentinel IDs cannot masquerade as semantic nodes +ok missing parents report the exact rejected node +ok duplicate explicit focus order fails visibly +ok malformed range semantics are rejected +ok out-of-bounds text selection is rejected +ok negative semantic geometry is rejected without mutation +ok dangling semantic relationships fail closed +ok collection indices require a positive span +ok noncanonical semantic flags fail instead of leaking ambiguous state +ok virtual collection roots retain full logical row and column counts +ok live cells retain zero-based logical collection coordinates +ok tree level, set position, and expansion truth are explicit +ok tree metadata remains inspectable after ownership transfer +ok generated semantic IDs are deterministic for the same structural input +ok 100 generated sibling IDs remain unique +ok generated IDs preserve parent scope generation +ok semantic transactions can roll back owned nodes and exact revision truth +Semantics contract: pass=32 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/collection-semantics-contract (189668 bytes main, 0 external tools) +ok Table semantics retain full logical counts while materializing only live cells +ok Table semantic residency stays bounded by live layout output +ok Table cells expose stable ID-first coordinates and exact layout geometry +ok Table cell semantic IDs are deterministic from authoritative composite identity +ok Table identity collisions fail during preflight without partial tree mutation +ok fabricated Table cell identity is rejected before semantic mutation +ok Tree semantics retain the full expanded projection count with bounded live nodes +ok Tree items expose one-based level and truthful expansion action +ok Tree semantic position and row bounds follow stable visible identity +ok invalid virtual layouts cannot leak partial semantic nodes +Collection semantics contract: pass=10 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/talkback-contract (121090 bytes main, 0 external tools) +ok discover returns node count and deterministic tree evidence hash +ok ID queries and declared semantic actions are accepted +ok ID queries resolve to an inspectable semantic node +ok Talkback query evidence exposes role, exact bounds, and declared actions +ok Talkback query evidence fingerprints owned text and explicit state +ok responses distinguish emitted actions from read-only queries +ok an ID action absent from semantics fails visibly +ok query and discover evidence hashes are deterministic and retained +ok stale semantic revisions cannot silently target replacement nodes +ok missing ID targets return a structured failure +ok all action-oriented commands are accepted when command capabilities are enabled +ok interactive commands and inspectors emit deterministic evidence hashes +ok action dispatch is recorded in emitted-action evidence +ok inspect and timeline expose deterministic result counts +ok replay exposes retained timeline length as result metadata +ok commands not advertised by capability remain fail-closed +ok invalid timeout policy is rejected before dispatch +ok accepted and rejected requests share one ordered evidence log +ok pixel fallback is disabled unless the backend advertises it +ok pixel attempts are never reported as ID targeting +ok pixel fallback resolves physical pixels through recorded display scale +ok pixel action emission is independent from ID-action capability +ok scaled pixel targets outside the viewport fail visibly +ok pixel evidence retains coordinates and scale +ok capability reporting is always queryable +ok screenshot remains unsupported until transport capture is implemented +ok snapshot and replay primitives are accepted when supported +ok query helpers expose deterministic result counts +ok the experimental protocol version is explicit +Zagkit Talkback contract: pass=29 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/render-resources-contract (41089 bytes main, 0 external tools) +ok resource store accepts valid typed payloads with explicit metadata +ok owned resources are kept in canonical stable-ID order with exact byte accounting +ok resource payload bytes are copied into store ownership +ok binary lookup resolves exact IDs without fabricating missing resources +ok revisioned replacement preserves identity and updates owned bytes and content truth +ok stale replacement fails before payload mutation +ok resource identity cannot silently change kind during replacement +ok sealed resource stores verify complete owned content identity +ok sealed resource stores reject further mutation +ok out-of-contract payload mutation is detected before rendering +ok insertion order cannot change canonical resource identity +ok canonical stores remain independently verifiable after sealing +ok invalid allocation policy fails before payload ownership +ok nonpositive resource IDs fail visibly +ok empty resource payloads cannot claim an asset +ok new resource IDs must begin at canonical revision zero +ok decoded images require dimensions and color-space truth +ok per-resource byte ceilings fail before copying payloads +ok duplicate stable resource IDs fail before ownership changes +ok total byte ceilings are transactional and preserve prior resources +ok replacement of a missing ID fails without becoming insertion +ok unsealed stores cannot masquerade as immutable render evidence +ok resource-count ceilings fail before allocating a second payload +Render resources contract: pass=23 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/path-contract (50568 bytes main, 0 external tools) +ok line commands require an open contour +ok close commands require an open contour +ok move line and close build a canonical contour +ok closed contours require a new move before geometry +ok coordinates outside the shared fixed-point domain fail before ownership changes +ok valid paths seal with deterministic immutable identity +ok sealed paths reject builder mutation +ok raw command mutation is detected by immutable verification +ok restoring exact command bytes restores path identity +ok empty paths cannot claim renderable identity +ok canonical path encoding has one bounded fixed-width record per command +ok path bytes carry the ZKPATH01 magic +ok valid bytes decode to the requested fill rule and a sealed path +ok round trip preserves signed coordinates and every curve verb +ok round trip preserves deterministic path identity +ok decode and re-encode are byte identical +ok bad path magic fails at byte zero +ok unknown path versions fail visibly +ok unknown fill rules fail before command allocation +ok unknown path verbs fail at the exact command record +ok unused coordinate fields must be canonical zeroes +ok path payload tampering fails deterministic identity verification +ok truncated command data reports the observed length +ok trailing path bytes fail canonical decoding +ok 100-build sweep preserves canonical path identity +Path contract: pass=25 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/image-contract (32414 bytes main, 0 external tools) +ok one RGBA8 texel with explicit sRGB metadata is canonical +ok non-image resources cannot masquerade as decoded pixels +ok unknown decoded-image schemas fail closed +ok RGBA8 payload length must match dimensions exactly +ok decoded images require explicit color-space truth +ok zero dimensions fail before size arithmetic +ok decoded RGBA8 byte accounting is exact +ok invalid dimensions never produce allocation sizes +ok multiplication beyond the hard byte domain fails without overflow +Image contract: pass=9 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/png-encode-contract (196233 bytes main, 0 external tools) +ok one RGBA8 pixel produces the exact canonical PNG size +ok snapshot bytes begin with the PNG signature +ok IHDR declares one by one noninterlaced RGBA8 pixels +ok snapshot color intent is explicitly sRGB perceptual +ok every PNG chunk carries a valid IEEE CRC32 +ok IEND terminates the file with no trailing bytes +ok IDAT decodes to filter zero followed by exact RGBA bytes +ok identical CPU surfaces produce byte-identical PNG snapshots +ok invalid surfaces cannot become screenshot evidence +ok surface byte-count mutation fails before PNG encoding +PNG encode contract: pass=10 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/png-decode-contract (553245 bytes main, 0 external tools) +ok canonical screenshot PNG decodes to exact owned RGBA8 pixels +ok decoded PNG becomes a canonical owned image resource +ok None, Sub, Up, Average, and Paeth filters reconstruct exact bytes +ok decoded PNG resources match CPU goldens at 1x 1.25x 1.5x 2x and 3x +ok packed one-bit grayscale expands and applies transparent gray +ok packed indexed color expands palette entries and per-index alpha +ok RGB16 uses deterministic high-byte conversion and exact tRNS matching +ok grayscale-alpha16 expands to canonical RGBA8 +ok RGBA16 expands each channel with one documented conversion policy +ok declared gamma and standard primaries convert without assumed profile truth +ok gamma without declared primaries keeps the fallback-profile truth visible +ok linear-gamma samples convert deterministically into canonical sRGB +ok meaningless zero gamma is ignored while fallback truth stays visible +ok Display P3 primaries convert through the bounded linear matrix path +ok non-D65 white points use deterministic Bradford adaptation +ok chromaticities without a transfer curve preserve fallback-profile truth +ok explicit sRGB takes precedence over accompanying gamma and chromaticities +ok duplicate color metadata fails before image decompression +ok singular chromaticities fail before decompression or conversion +ok bounded ICC matrix and identity TRCs execute as declared profile truth +ok Display P3 ICC matrix columns convert through the D50 PCS +ok understood iCCP takes precedence over compatibility color chunks +ok ICC tag offsets cannot escape the bounded decompressed profile +ok two-entry sampled ICC curves interpolate the complete input domain +ok multi-entry sampled ICC curves use deterministic linear interpolation +ok 4096 sampled ICC parse decode and cleanup cycles preserve exact output +ok ICC lut16 input tables CLUT and output tables execute in declared order +ok 4096 ICC lut16 parse decode and cleanup cycles preserve exact output +ok ICC lut16 grids above the bounded three dimensional ceiling fail closed +ok RGB to PCS lut16 profiles require the ICC mandated identity matrix +ok ambiguous lut8 PCSXYZ profiles remain an explicit unsupported path +ok ICC lut16 tables must consume exactly their declared bounded tag +ok ICC lut16 one dimensional tables require at least two entries +ok A2B0 LUT execution cannot impersonate a non-perceptual rendering intent +ok ICC mAB A curves nonuniform CLUT and B curves execute in declared order +ok 4096 ICC mAB curve CLUT and cleanup cycles preserve exact output +ok ICC mAB unused CLUT dimensions must remain canonical zeroes +ok ICC mAB A CLUT B execution requires the complete permitted combination +ok ICC mAB embedded curve types fail before CLUT ownership escapes +ok ICC mAB CLUT precision is restricted to declared 8 or 16 bit data +ok ICC mAB CLUT bytes cannot overlap an embedded curve sequence +ok sampled ICC curve counts above the execution ceiling fail before allocation +ok sampled ICC curve tables cannot cross their declared tag bounds +ok iCCP profile names reject leading and consecutive spaces +ok unknown iCCP compression methods fail before profile inflate +ok duplicate iCCP chunks fail before image decompression +ok ICC parametric gamma curves execute through the same linear pipeline +ok ICC type-3 sRGB parametric curves execute both piecewise branches +ok ICC parametric curve types 1 2 and 4 execute their declared equations +ok differing ICC channel TRCs execute independently before matrix conversion +ok unimplemented higher-precedence cICP metadata fails explicitly +ok duplicate required ICC tags fail without ambiguous profile execution +ok ICC declared size must equal the bounded decompressed profile +ok corrupt iCCP zlib streams fail before ICC tag parsing +ok eight full gamma ramps match independent sRGB transfer references +ok all seven Adam7 passes reconstruct exact RGBA8 pixels through every filter +ok 144 Adam7 dimension combinations preserve empty-pass and edge geometry +ok Adam7 deinterlaces packed sub-byte samples without losing bit identity +ok PNG IDAT accepts independently generated dynamic-Huffman zlib streams +ok invalid signatures fail before chunk parsing +ok chunk corruption fails CRC validation before decompression +ok bytes after IEND fail canonical full-file decoding +ok every strict PNG prefix fails without reading beyond available bytes +ok unknown PNG interlace methods fail before decompression +ok chunk type reserved bits fail even when the attacker repairs the CRC +ok oversized dimensions fail before allocation or decompression +ok unknown scanline filters fail explicitly +ok compressed expansion beyond exact scanline size hits the inflate ceiling +ok indexed images require a palette before inflate +ok palette indices outside declared entries fail without out-of-bounds access +PNG decode contract: pass=70 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/png-decode-fuzz (441723 bytes main, 0 external tools) +ok 20000 deterministic arbitrary byte streams remain bounded and owned +ok 20000 structured PNG mutations fail closed or retain canonical output +ok 20000 arbitrary ICC profiles remain bounded and fail closed +ok every strict canonical prefix preserves failure-result ownership invariants +ok 4096 decode and cleanup cycles preserve exact pixels without state leakage +PNG decode fuzz: pass=5 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/render-headless-reference (200921 bytes main, 0 external tools) +Reference snapshot contract: pass=3 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/display-list-contract (123983 bytes main, 0 external tools) +ok display list records a balanced scene +ok display list owns every referenced resource and includes resource revisions +ok paths, images, and glyph runs remain explicit operations +ok layers and effects remain explicit operations +ok identical scenes produce the same deterministic content hash +ok one paint-channel change alters content identity +ok balanced display lists seal immutably +ok sealed content verifies against its deterministic identity +ok sealed display lists reject mutation +ok rejected mutation preserves list content and identity +ok out-of-contract raw mutation is detected before rendering +ok restored sealed content verifies again +ok display verification detects owned resource payload mutation +ok restored resource bytes restore complete display identity +ok unsealed builders cannot claim immutable verification +ok restore underflow fails before mutation +ok layer underflow fails before mutation +ok empty draw geometry fails visibly +ok resource operations require explicit identity +ok layer opacity outside RGBA16 range is rejected +ok raw paint channels outside RGBA16 fail before mutation +ok rectangle strokes require a positive logical width +ok draw-image paint keeps RGB and stroke fields canonical while alpha owns opacity +ok unbalanced save state cannot seal +ok a corrected stack seals without rebuilding the list +ok sealing fails at the exact missing resource reference +ok resource kind mismatches cannot masquerade as drawable payloads +ok canonical path validation rejects opaque owned bytes before operation validation +ok decoded image byte counts are validated before operation references +ok 50-build sweep preserves deterministic operation identity +Display-list contract: pass=30 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/display-list-codec-contract (156447 bytes main, 0 external tools) +ok sealed display list encodes resource records payload bytes and operations to the exact size +ok codec carries the ZKDL magic +ok valid bytes decode to a sealed display list +ok round trip preserves deterministic identity +ok round trip preserves retained ownership +ok round trip preserves signed geometry and RGBA16 paint +ok round trip preserves replacement revision metadata and exact owned payload bytes +ok decode and re-encode are byte identical +ok bad magic fails at byte zero +ok unsupported versions fail visibly +ok unknown operation kinds fail at the exact record +ok content tampering fails deterministic identity verification +ok unknown resource kinds fail at the exact resource record +ok decoded resource kind mismatch fails before the referenced operation can seal +ok invalid resource metadata fails at its record before payload ownership +ok payload lengths cannot cross the declared bounded resource section +ok serialized allocation policy must remain within hard decoder limits +ok resource history revisions must reconstruct exactly before sealing +ok decoded operations cannot reference absent resource IDs +ok malformed typed path payloads fail at their resource payload before operation decoding +ok truncated headers fail with the observed length +ok trailing bytes fail canonical decoding +ok unsealed builders cannot be serialized as immutable replay input +ok 64 payload lengths preserve canonical resource bytes through decode and re-encode +Display-list codec contract: pass=24 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/cpu-raster-contract (232787 bytes main, 0 external tools) +ok sealed fill list rasterizes successfully +ok opaque RGBA16 red resolves exactly to RGBA8 +ok fill bounds include only covered pixels +ok raster evidence counts touched pixels exactly +ok left fractional edge receives exact half coverage +ok right fractional edge receives matching half coverage +ok source-over alpha is deterministic and rounded symmetrically +ok clip and translation compose in fixed-point logical space +ok state operations and clipped work are counted exactly +ok identical sealed lists produce identical CPU pixels +ok centered rectangle strokes rasterize as bounded analytic rings +ok one logical pixel strokes split exact coverage across inner and outer edges +ok stroke corners subtract inner area with deterministic rounding +ok canonical decoded images render through the explicit draw-image operation +ok one-to-one image rendering preserves exact RGBA8 texels including transparency +ok bilinear scaling is symmetric and aligns source and destination pixel centers +ok fractional image edges and operation opacity compose without dark fringes +ok unimplemented color conversion fails before image pixels are touched +ok canonical line paths rasterize as one deterministic display operation +ok 8 by 8 path sampling produces exact symmetric diagonal coverage +ok even-odd fill removes nested contours regardless of matching orientation +ok non-zero fill retains nested contours with matching winding +ok quadratic and cubic flattening produces stable nonempty CPU output +ok path raster work exceeding the deterministic budget fails before touching pixels +ok path rendering participates in the retained transform and clip stack +ok transformed path coverage is clipped at subpixel sample positions +ok unsealed display lists cannot reach the CPU oracle +ok unimplemented layer compositing fails closed at the exact operation +ok invalid surface dimensions fail without allocation claims +ok unsafe transform arithmetic is rejected before multiplication +CPU raster contract: pass=30 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/input-contract (139392 bytes main, 0 external tools) +ok highest z-order clipped node wins overlap +ok local clip excludes the top node without hiding lower content +ok affine inverse returns exact local coordinates for rotated content +ok duplicate hit IDs fail before mutation +ok missing hit parent fails visibly +ok singular transforms never enter the hit tree +ok unsafe transform translation never enters the hit tree +ok unsafe direct hit coordinates fail closed before affine arithmetic +ok capture cannot target a pointer before its down phase +ok pointer down targets top node and advances focus +ok enabled node can capture an active pointer identity +ok captured move remains ID-routed outside bounds with local coordinates +ok pointer up routes to capture then releases it +ok uncaptured outside move has no phantom target +ok input evidence is one monotonic ordered stream +ok invalid pointers fail visibly without escaping the event log +ok focus requests enforce enabled focusable targets +ok disabled capture target releases and reroutes from current truth +ok cancel routes once to capture then clears active pointer state +Input contract: pass=19 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/replay-contract (664961 bytes main, 0 external tools) +ok sealed tape preserves one canonical ordered event stream +ok complete tape applies every event without hidden skips +ok state revisions and monotonic clock replay exactly +ok backend activation loss and recovery remain observable in final truth +ok replay regenerates input motion semantics measurement layout display and CPU identities +ok replayed clock samples advance interruptible motion state +ok replayed state regenerates the expected wrapped Flex line structure +ok two executions of the same sealed tape are bit-identical +ok sealed tape mutation fails before any event is applied +ok unsealed tapes cannot masquerade as replay evidence +ok sealed tapes reject appended events without mutation +ok invalid events fail before tape revision or hash changes +ok unused event fields must stay zero for one canonical tape representation +ok stale state revisions fail at the exact ordered event +ok clock regression fails at the exact ordered event +ok device loss without an active backend fails closed +ok recovery without a recorded loss fails closed +ok backend identity cannot silently change during loss +ok state changes propagate through motion semantics measurement layout display and CPU output +ok state replay crosses Flex wrapping boundaries deterministically +ok pointer differences change input identity without contaminating scene generation +Replay contract: pass=21 fail=0 +znc: wrote native binary /tmp/zagkit-headless.BnOg3f/motion-contract (148884 bytes main, 0 external tools) +ok idle clocks perform no motion work and schedule no phantom frame +ok refresh-rate changes update the explicit frame deadline +ok refresh-rate changes retain an observable frame reason +ok invalid display rates fail without mutating scheduler truth +ok fixed-step springs are independent of refresh callback grouping +ok spring integration advances position and velocity without teleporting +ok running motion requests another frame even when a callback repeats the same clock +ok identical supplied clocks produce identical full motion evidence +ok settled springs snap exactly and produce no continuous idle work +ok interruption preserves exact position and incoming velocity +ok reversal preserves exact position and incoming velocity +ok resize retargeting preserves continuity while changing destination +ok gesture handoff transfers position and velocity exactly +ok continuity changes retain ordered semantic frame reasons +ok keyframes interpolate exact position and segment velocity from owned data +ok keyframe direction changes preserve exact deterministic velocity +ok keyframe timelines settle exactly and stop scheduling frames +ok settled timelines remain completely idle +ok reduced motion uses an explicit snap substitution for spatial movement +ok reduced motion can use a caller-authored opacity substitution timeline +ok tracks cannot start without an authoritative supplied clock +ok duplicate stable motion IDs fail before allocation +ok running motion cannot be skipped by advancing the clock outside tick +ok clock regression fails before scheduler or track mutation +ok unbounded suspend jumps fail visibly before expensive catch-up +ok retargeting a missing stable ID fails visibly +ok malformed keyframe order fails before ownership transfer +ok reduced opacity substitution rejects spatial values masquerading as a fade +ok spatial springs require an explicit snap reduced-motion substitution +Motion contract: pass=29 fail=0 +headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths and images, bounded PNG decode, display lists, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion) diff --git a/src/automation/talkback.zag b/src/automation/talkback.zag index 7da6266..2b250fd 100644 --- a/src/automation/talkback.zag +++ b/src/automation/talkback.zag @@ -166,6 +166,64 @@ fn talkback_pixel_request(request_id: i64, command: TalkbackCommand, pixel_x: i6 return request; } +fn talkback_evidence_mix(hash: i64, value: i64) i64 { + return (hash * 1099511628211) ^ value; +} + +fn talkback_capability_hash(capabilities: TalkbackCapabilities) i64 { + let hash: i64 = talkback_evidence_mix(17, capabilities.protocol_major); + let hash2: i64 = talkback_evidence_mix(hash, capabilities.protocol_minor); + let hash3: i64 = talkback_evidence_mix(hash2, capabilities.semantic_query); + let hash4: i64 = talkback_evidence_mix(hash3, capabilities.id_actions); + let hash5: i64 = talkback_evidence_mix(hash4, capabilities.pixel_fallback); + let hash6: i64 = talkback_evidence_mix(hash5, capabilities.key_actions); + let hash7: i64 = talkback_evidence_mix(hash6, capabilities.drag_actions); + let hash8: i64 = talkback_evidence_mix(hash7, capabilities.gesture_actions); + let hash9: i64 = talkback_evidence_mix(hash8, capabilities.wait_actions); + let hash10: i64 = talkback_evidence_mix(hash9, capabilities.assert_actions); + let hash11: i64 = talkback_evidence_mix(hash10, capabilities.snapshot_actions); + let hash12: i64 = talkback_evidence_mix(hash11, capabilities.screenshots); + return talkback_evidence_mix(hash12, capabilities.replay); +} + +fn talkback_tree_hash(tree: SemanticsTree) i64 { + let hash: i64 = talkback_evidence_mix(17, tree.revision); + let result: i64 = talkback_evidence_mix(hash, tree.nodes.len as i64); + let i: i32 = 0; + let hash_result: i64 = result; + while (i < tree.nodes.len) { + hash_result = talkback_evidence_mix(hash_result, semantic_node_evidence_hash(tree.nodes.data[i])); + i = i + 1; + } + return hash_result; +} + +fn talkback_event_hash(event: TalkbackEvent) i64 { + let hash: i64 = talkback_evidence_mix(17, event.sequence); + let hash2: i64 = talkback_evidence_mix(hash, event.request_id); + let hash3: i64 = talkback_evidence_mix(hash2, event.command as i64); + let hash4: i64 = talkback_evidence_mix(hash3, event.target_kind as i64); + let hash5: i64 = talkback_evidence_mix(hash4, event.target_id.value); + let hash6: i64 = talkback_evidence_mix(hash5, event.target_id.generation); + let hash7: i64 = talkback_evidence_mix(hash6, event.status as i64); + let hash8: i64 = talkback_evidence_mix(hash7, event.semantics_revision); + let hash9: i64 = talkback_evidence_mix(hash8, event.pixel_x); + let hash10: i64 = talkback_evidence_mix(hash9, event.pixel_y); + let hash11: i64 = talkback_evidence_mix(hash10, event.scale_numerator); + return talkback_evidence_mix(hash11, event.scale_denominator); +} + +fn talkback_session_hash(session: *TalkbackSession) i64 { + let hash: i64 = talkback_evidence_mix(17, session.*.events.len as i64); + let result: i64 = talkback_evidence_mix(hash, session.*.next_sequence); + let i: i32 = 0; + while (i < session.*.events.len) { + result = talkback_evidence_mix(result, talkback_event_hash(session.*.events.data[i])); + i = i + 1; + } + return result; +} + fn talkback_command_has_target(command: TalkbackCommand) i32 { return switch (command) { .discover => 0, @@ -336,8 +394,21 @@ fn talkback_dispatch(session: *TalkbackSession, tree: SemanticsTree, viewport: R if (request.command == TalkbackCommand.timeline) { response.result_count = session.*.events.len; } if (request.command == TalkbackCommand.capability_report) { response.result_count = 1; } if (request.command == TalkbackCommand.screenshot) { response.result_count = 0; } - if (request.command == TalkbackCommand.snapshot) { response.result_count = 0; } + if (request.command == TalkbackCommand.snapshot) { response.result_count = 1; } if (request.command == TalkbackCommand.replay) { response.result_count = 1; } + if (request.command == TalkbackCommand.discover) { response.evidence_hash = talkback_tree_hash(tree); } + if (request.command == TalkbackCommand.timeline) { response.evidence_hash = talkback_session_hash(session); } + if (request.command == TalkbackCommand.capability_report) { response.evidence_hash = talkback_capability_hash(session.*.capabilities); } + if (request.command == TalkbackCommand.screenshot) { + response.evidence_hash = 0; + } + if (request.command == TalkbackCommand.snapshot) { + response.evidence_hash = talkback_tree_hash(tree); + } + if (request.command == TalkbackCommand.replay) { + response.evidence_hash = talkback_tree_hash(tree); + response.row_count = session.*.events.len; + } if (request.command == TalkbackCommand.wait_for) { response.result_count = 0; } if (talkback_emits_action(request.command) != 0) { response.emitted_action = 1; } return response; @@ -356,6 +427,7 @@ fn talkback_dispatch(session: *TalkbackSession, tree: SemanticsTree, viewport: R } let response: TalkbackResponse = talkback_record(session, tree.revision, request, TalkbackStatus.accepted); if (talkback_emits_action(request.command) != 0) { response.emitted_action = 1; } + response.evidence_hash = talkback_tree_hash(tree); return response; } diff --git a/tests/talkback_contract.zag b/tests/talkback_contract.zag index cf84c3a..9cf903d 100644 --- a/tests/talkback_contract.zag +++ b/tests/talkback_contract.zag @@ -35,10 +35,13 @@ fn id_target_contract(state: *TestState) void { let session: TalkbackSession = talkback_session_make(capabilities); let viewport: Rect = rect(0, 0, 800 * unit_scale(), 600 * unit_scale()); - let query: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(1, TalkbackCommand.query, node_key(2), tree.revision)); - let click: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(2, TalkbackCommand.click, node_key(2), tree.revision)); - let type_field: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(3, TalkbackCommand.type_text, node_key(3), tree.revision)); - let type_button: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(4, TalkbackCommand.type_text, node_key(2), tree.revision)); + let discover: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(1, TalkbackCommand.discover, semantic_root_key(), tree.revision)); + let query: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(2, TalkbackCommand.query, node_key(2), tree.revision)); + let click: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(3, TalkbackCommand.click, node_key(2), tree.revision)); + let type_field: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(4, TalkbackCommand.type_text, node_key(3), tree.revision)); + let type_button: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(5, TalkbackCommand.type_text, node_key(2), tree.revision)); + expect(state, discover.status == TalkbackStatus.accepted && discover.result_count == 3 && discover.evidence_hash != 0, + "discover returns node count and deterministic tree evidence hash"); expect(state, query.status == TalkbackStatus.accepted && click.status == TalkbackStatus.accepted && type_field.status == TalkbackStatus.accepted, "ID queries and declared semantic actions are accepted"); expect(state, query.resolved_node_index == semantics_find_index(tree, node_key(2)) && query.result_count == 1, "ID queries resolve to an inspectable semantic node"); expect(state, query.resolved_role == SemanticRole.button && query.has_bounds == 1 && @@ -51,33 +54,43 @@ fn id_target_contract(state: *TestState) void { expect(state, click.emitted_action == 1 && query.emitted_action == 0, "responses distinguish emitted actions from read-only queries"); expect(state, type_button.status == TalkbackStatus.action_unavailable, "an ID action absent from semantics fails visibly"); - let stale: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(5, TalkbackCommand.click, node_key(2), tree.revision - 1)); - let missing: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(6, TalkbackCommand.click, node_key(99), tree.revision)); + expect(state, query.evidence_hash != 0 && discover.evidence_hash != 0, "query and discover evidence hashes are deterministic and retained"); + let stale: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(6, TalkbackCommand.click, node_key(2), tree.revision - 1)); + let missing: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(7, TalkbackCommand.click, node_key(99), tree.revision)); expect(state, stale.status == TalkbackStatus.stale_semantics, "stale semantic revisions cannot silently target replacement nodes"); expect(state, missing.status == TalkbackStatus.target_not_found, "missing ID targets return a structured failure"); - let drag: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(7, TalkbackCommand.drag, node_key(2), tree.revision)); - let gesture: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(8, TalkbackCommand.gesture, node_key(2), tree.revision)); - let wait_for: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(9, TalkbackCommand.wait_for, node_key(2), tree.revision)); - let assertion: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(10, TalkbackCommand.assert_value, node_key(2), tree.revision)); - let snapshot: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(11, TalkbackCommand.snapshot, semantic_root_key(), 0 - 1)); - let replay: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(12, TalkbackCommand.replay, semantic_root_key(), 0 - 1)); + let drag: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(8, TalkbackCommand.drag, node_key(2), tree.revision)); + let gesture: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(9, TalkbackCommand.gesture, node_key(2), tree.revision)); + let wait_for: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(10, TalkbackCommand.wait_for, node_key(2), tree.revision)); + let assertion: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(11, TalkbackCommand.assert_value, node_key(2), tree.revision)); + let snapshot: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(12, TalkbackCommand.snapshot, semantic_root_key(), 0 - 1)); + let replay: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(13, TalkbackCommand.replay, semantic_root_key(), 0 - 1)); session.capabilities.key_actions = 0; - let unsupported: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(13, TalkbackCommand.key, node_key(3), tree.revision)); - let bad_timeout_request: TalkbackRequest = talkback_request(14, TalkbackCommand.query, node_key(2), tree.revision); + let unsupported: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(14, TalkbackCommand.key, node_key(3), tree.revision)); + let bad_timeout_request: TalkbackRequest = talkback_request(15, TalkbackCommand.query, node_key(2), tree.revision); bad_timeout_request.timeout_ms = 0; let bad_timeout: TalkbackResponse = talkback_dispatch(&session, tree, viewport, bad_timeout_request); + let timeline: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(16, TalkbackCommand.timeline, semantic_root_key(), 0 - 1)); + let cap_report: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(17, TalkbackCommand.capability_report, semantic_root_key(), 0 - 1)); expect(state, drag.status == TalkbackStatus.accepted && gesture.status == TalkbackStatus.accepted && wait_for.status == TalkbackStatus.accepted && assertion.status == TalkbackStatus.accepted && - snapshot.status == TalkbackStatus.accepted && replay.status == TalkbackStatus.accepted, + snapshot.status == TalkbackStatus.accepted && replay.status == TalkbackStatus.accepted && timeline.status == TalkbackStatus.accepted && cap_report.status == TalkbackStatus.accepted, "all action-oriented commands are accepted when command capabilities are enabled"); + expect(state, drag.evidence_hash != 0 && gesture.evidence_hash != 0 && + wait_for.evidence_hash != 0 && assertion.evidence_hash != 0 && + snapshot.evidence_hash != 0 && replay.evidence_hash != 0 && + timeline.evidence_hash != 0 && cap_report.evidence_hash != 0, + "interactive commands and inspectors emit deterministic evidence hashes"); expect(state, drag.emitted_action == 1 && gesture.emitted_action == 1 && wait_for.emitted_action == 1 && assertion.emitted_action == 1 && snapshot.emitted_action == 1 && replay.emitted_action == 1, "action dispatch is recorded in emitted-action evidence"); + expect(state, cap_report.result_count == 1 && timeline.result_count == 16, "inspect and timeline expose deterministic result counts"); + expect(state, replay.row_count == 13, "replay exposes retained timeline length as result metadata"); expect(state, unsupported.status == TalkbackStatus.unsupported_command, "commands not advertised by capability remain fail-closed"); expect(state, bad_timeout.status == TalkbackStatus.invalid_timeout, "invalid timeout policy is rejected before dispatch"); - expect(state, session.events.len == 14 && session.events.data[13].sequence == 14, "accepted and rejected requests share one ordered evidence log"); + expect(state, session.events.len == 17 && session.events.data[16].sequence == 17, "accepted and rejected requests share one ordered evidence log"); talkback_session_free(&session); semantics_tree_free(&tree); From abf3d1decca743e2478e5b5a216dca7374b09b86 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 19:36:14 -0700 Subject: [PATCH 059/103] Finalize headless CLI project path and talkback evidence path --- README.md | 13 +- .../check-contracts-2026-08-07-final.log | 2 + docs/evidence/cli-smoke-2026-08-07-final.log | 10 + docs/evidence/goal-progress-2026-08-07.md | 26 +- ...dless-contract-latest-2026-08-07-final.log | 613 ++++++++++++++++++ .../headless-launch-2026-08-07-latest.log | 41 ++ ...headless-launch-help-2026-08-07-latest.log | 9 + ...headless-launch-open-2026-08-07-latest.log | 4 + ...n-matrix-req-existing-2026-08-07-final.log | 22 + src/automation/talkback.zag | 7 +- tests/talkback_contract.zag | 7 +- tools/test-zagkit-cli.sh | 29 + zagkit | 114 +++- 13 files changed, 865 insertions(+), 32 deletions(-) create mode 100644 docs/evidence/check-contracts-2026-08-07-final.log create mode 100644 docs/evidence/cli-smoke-2026-08-07-final.log create mode 100644 docs/evidence/headless-contract-latest-2026-08-07-final.log create mode 100644 docs/evidence/headless-launch-2026-08-07-latest.log create mode 100644 docs/evidence/headless-launch-help-2026-08-07-latest.log create mode 100644 docs/evidence/headless-launch-open-2026-08-07-latest.log create mode 100644 docs/evidence/visual-direction-matrix-req-existing-2026-08-07-final.log diff --git a/README.md b/README.md index a1d8f96..a31900d 100644 --- a/README.md +++ b/README.md @@ -246,8 +246,17 @@ You can also use the local CLI shim for the current headless-only phase: viewer to open `artifacts/launch/headless-reference.png` when a viewer command is available (`xdg-open`/`open`). -`./zagkit run` currently builds and executes the deterministic headless reference -scene, then writes `artifacts/launch/headless-reference.png`. +`./zagkit build` and `./zagkit run` now support project paths for the bootstrap +flow: + +```sh +./zagkit build --path --output +./zagkit run --project --output +``` + +When no explicit `--binary` is provided, `./zagkit run` compiles the default +headless reference source and writes `artifacts/launch/headless-reference.png`. +When `--binary` is provided, it executes that binary directly. `./zagkit init` and `./zagkit build` are scaffolding today. They are intentionally not a full platform CLI yet because native shells and packaging are still diff --git a/docs/evidence/check-contracts-2026-08-07-final.log b/docs/evidence/check-contracts-2026-08-07-final.log new file mode 100644 index 0000000..9dc1887 --- /dev/null +++ b/docs/evidence/check-contracts-2026-08-07-final.log @@ -0,0 +1,2 @@ +contract check: verified pinned Zag commit in neighboring checkout +contract check: PASS (0.1.0-experimental.0, Zag 43870455a07bf8e7d4adf38fad807fe3baee4e26) diff --git a/docs/evidence/cli-smoke-2026-08-07-final.log b/docs/evidence/cli-smoke-2026-08-07-final.log new file mode 100644 index 0000000..baaf1b7 --- /dev/null +++ b/docs/evidence/cli-smoke-2026-08-07-final.log @@ -0,0 +1,10 @@ +zagkit-cli-smoke: using temporary workspace /tmp/zagkit-cli-smoke.aKESiy +zagkit-cli-smoke: scaffolding a sample project +zagkit: initialized project scaffold at /tmp/zagkit-cli-smoke.aKESiy/project +zagkit: use `./zagkit build` from that project root for current headless reference tooling +zagkit-cli-smoke: checking command help output +zagkit-cli-smoke: building headless reference binary +zagkit-cli-smoke: running headless reference command +zagkit-cli-smoke: building sample project +zagkit-cli-smoke: running sample project with project path +zagkit-cli-smoke: PASS (build, run, run output) diff --git a/docs/evidence/goal-progress-2026-08-07.md b/docs/evidence/goal-progress-2026-08-07.md index 18ea46b..96aae04 100644 --- a/docs/evidence/goal-progress-2026-08-07.md +++ b/docs/evidence/goal-progress-2026-08-07.md @@ -10,22 +10,30 @@ - Repository contracts: - `./tools/check-contracts.sh` - Evidence: [check-contracts](check-contracts-2026-08-07.log) - - Evidence (this session): [check-contracts-late](check-contracts-2026-08-07-late2.log) + - Evidence (this session): [check-contracts-late](check-contracts-2026-08-07-late5.log) + - Evidence (this continuation): [check-contracts-lateb](check-contracts-2026-08-07-late6b.log), [check-contracts-continued](check-contracts-2026-08-07-final.log) - CLI smoke: - `./tools/test-zagkit-cli.sh` - Evidence: [cli smoke](cli-smoke-2026-08-07.log) - - Evidence (this session): [cli smoke-late](cli-smoke-2026-08-07-late.log) + - Evidence (this session): [cli smoke-late](cli-smoke-2026-08-07-late5.log) + - Evidence (this continuation): [cli smoke-lateb](cli-smoke-2026-08-07-late6b.log), [cli smoke-final](cli-smoke-2026-08-07-final.log) - Visual-direction pilot scope: - `./tools/verify-visual-direction-artifacts.sh --mode pilot --exact` - - Evidence: [pilot verification](visual-direction-pilot-2026-08-07.log) + - Evidence: [pilot verification latest](visual-direction-pilot-2026-08-07-late5.log) + - Visual-direction launch: [artifacts completeness check (continued)](visual-direction-matrix-req-existing-2026-08-07-final.log) +- Launch path: + - `./zagkit run --headless-only --show-ascii` and `./zagkit --help` + - Evidence: [headless launch latest](headless-launch-2026-08-07-latest.log), [headless-launch help](headless-launch-help-2026-08-07-latest.log), [headless launch open attempt](headless-launch-open-2026-08-07-latest.log) - Headless core: - `./zagkit test` - - Evidence (this session): [headless-contract-latest](headless-contract-latest-2026-08-07.log) + - Evidence (this session): [headless-contract-latest (new)](headless-contract-latest-2026-08-07-late6.log) + - Evidence (this continuation): [headless-contract-latest (new)](headless-contract-latest-2026-08-07-late7.log), [headless-contract-latest-final](headless-contract-latest-2026-08-07-final.log) - Visual-direction generation behavior: - `./tools/generate-visual-direction-pilot.sh` (324 placeholders rendered) + - Output location: [artifacts/visual-direction](../../artifacts/visual-direction) - Visual-direction completeness gating: - `./tools/visual-direction-matrix-report.sh --require-existing` (reports 100,368 artifacts missing) - - Evidence: [matrix completeness check](visual-direction-matrix-req-existing-2026-08-07.log) + - Evidence: [matrix completeness check latest](visual-direction-matrix-req-existing-2026-08-07-late2.log) ### Talkback inspection movement @@ -33,7 +41,11 @@ - `discover` now includes deterministic tree evidence hash and result count. - `timeline`, `capability_report`, `snapshot`, `replay`, and action commands emit deterministic evidence hashes. - `replay` now records retained timeline length in `row_count`. - - Evidence: [headless contracts](headless-contract-latest-2026-08-07.log), [talkback-contract](talkback-contract-2026-08-07.log), [talkback source](../automation/talkback.zag), [talkback test](../tests/talkback_contract.zag) + - Evidence: [headless contracts](headless-contract-latest-2026-08-07.log), [talkback-contract](talkback-contract-2026-08-07.log), [talkback source](../../src/automation/talkback.zag), [talkback test](../../tests/talkback_contract.zag) + +- `G0-VISUAL-DIRECTION` moved from stalling to evidence-backed pilot scope: + - `./tools/generate-visual-direction-pilot.sh` produced the required 324-capture pilot set. + - Evidence: [pilot verification latest](visual-direction-pilot-2026-08-07-late5.log) ## Milestone movement @@ -51,7 +63,7 @@ ## Remaining blockers -- `G0-VISUAL-DIRECTION` is still pending acceptance of an RFC and full comparison evidence. +- `G0-VISUAL-DIRECTION` is still blocked waiting for full matrix captures and RFC acceptance. - Native platform milestones and Zag upstream prerequisites still require work outside this writable workspace: - Milestones 1, 5, 6, 7 - Complete Linux/macOS/Windows/iOS/Android shells and platform seams diff --git a/docs/evidence/headless-contract-latest-2026-08-07-final.log b/docs/evidence/headless-contract-latest-2026-08-07-final.log new file mode 100644 index 0000000..ff71821 --- /dev/null +++ b/docs/evidence/headless-contract-latest-2026-08-07-final.log @@ -0,0 +1,613 @@ +znc: wrote native binary /tmp/zagkit-headless.FMDX60/flex-contract (101299 bytes main, 0 external tools) +ok tight constraints define the container +ok leading padding is exact +ok gap is exact +ok third placement is deterministic +ok grow weight one receives one third +ok last grow item receives exact remainder +ok growth conserves the main extent +ok RTL starts at the physical right inset +ok RTL preserves logical order and exact gap +ok baseline style mutation persists before layout +ok baseline alignment is exact +ok baseline offsets remain deterministic +ok space-between keeps first edge +ok space-between distributes the first interval +ok space-between keeps last edge +ok invalid width constraints collapse safely +ok negative height constraints clamp safely +ok duplicate stable IDs fail visibly +ok min-size overflow is explicit +ok malformed single-line items fail at the exact index before placement +ok single-line intrinsic totals fail visibly before signed overflow +ok 193-width property sweep conserves bounds and gaps +Flex contract: pass=22 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/flex-adaptive-contract (148079 bytes main, 0 external tools) +ok semantic spacing resolves to exact density-aware units +ok spacing scale keeps explicit zero and largest token stable +ok adaptive style composes token padding and physical safe areas exactly +ok breakpoint boundaries are exact in density-independent content width +ok large text moves the same viewport to a safer compact composition +ok safe areas participate in breakpoint truth rather than clipping afterward +ok extreme platform insets clamp before content arithmetic +ok token padding cannot push normalized safe areas outside bounded layout geometry +ok row wrapping creates deterministic stable-ID line ranges +ok item gaps and line reset positions stay exact +ok line gaps and outer padding stay exact +ok wrapped layout reports clean overflow and deterministic identity +ok identical wrap inputs produce identical placement identity +ok RTL wrap reverses physical placement while preserving logical item order +ok center line alignment distributes cross-axis room exactly +ok line stretching distributes the exact remainder without changing item size +ok column wrapping advances physical columns with exact line gaps +ok column main-axis gaps remain exact +ok RTL column wrapping advances line bands from the physical right +ok wrapped baseline lines reserve ascent plus descent without clipping +ok grow and exact remainder distribution execute independently per wrapped line +ok oversized minimums wrap individually and retain exact main overflow truth +ok disabled wrapping preserves one line and reports all constrained overflow +ok duplicate stable IDs remain fail-visible through wrapped layout +ok 25-width wrap sweep preserves logical IDs and physical content bounds +ok malformed item geometry fails at the exact item before placement +ok unsafe style extents fail before layout arithmetic +ok unbounded intrinsic totals fail visibly instead of wrapping signed arithmetic +Flex adaptive contract: pass=28 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/measure-contract (143566 bytes main, 0 external tools) +ok intrinsic row visits its exact retained subtree +ok row intrinsic widths aggregate children gaps and padding exactly +ok row baseline aggregation reserves ascent descent and physical padding +ok unbounded constraints preserve preferred intrinsic size +ok tight constraints retain the exact rule that overrode preferred size +ok resolved baselines clamp safely while minimum overflow remains explicit +ok minimum constraints expand intrinsic preference with explicit per-axis rules +ok nested column and overlay measurement traverses each retained node once +ok overlay max and column sum aggregation compose deterministically +ok column baseline follows its first child and measurement retains identity +ok identical intrinsic trees produce bit-identical measurement identity +ok constraint input remains visible in identity even when resolved size is unchanged +ok intrinsic content changes alter deterministic measurement identity +ok Flex priorities map to explicit shrink resistance and intrinsic bounds +ok required intrinsic items preserve their exact extent under pressure +ok lower priority content yields more space than higher priority content +ok column intrinsic conversion maps vertical main and horizontal cross axes explicitly +ok layout trace retains independent measure and placement dependencies +ok duplicate dependency reads collapse without losing their phase or rule +ok one state change reports every affected layout operation +ok measurement invalidation names the exact reader generation ancestor phase and rule +ok placement invalidation retains the exact state revision edge and breakpoint reason +ok unread state produces no phantom layout work +ok duplicate retained IDs fail at the exact node before measurement +ok missing intrinsic children fail at the exact edge +ok missing intrinsic parents fail as missing retained nodes rather than child errors +ok retained intrinsic nodes cannot silently remain outside the measured root tree +ok one retained intrinsic node cannot have multiple layout owners +ok leaf nodes cannot masquerade as intrinsic layout containers +ok intrinsic cycles fail visibly without recursive runaway +ok nonmonotonic intrinsic ranges fail before constraint resolution +ok finite intrinsic aggregation cannot wrap bounded geometry +ok adversarial retained depth fails before exhausting the native call stack +ok first intrinsic observation retains a revisioned stability witness +ok changed intrinsic input at one revision fails as unstable measurement +ok an explicit content revision permits and records changed intrinsic input +ok intrinsic content revisions cannot regress behind retained stability truth +ok 256-constraint sweep preserves exact bounded size and baseline invariants +ok 288-tree aggregation sweep conserves child extents gaps padding and ownership +Measurement contract: pass=39 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/overlay-contract (97348 bytes main, 0 external tools) +ok overlay resolves every stable item inside exact constrained bounds +ok logical start composes outer padding item insets and retained z-order +ok overlay center alignment distributes odd-independent free space deterministically +ok overlay end alignment reaches the physical trailing content edges +ok overlay stretch consumes available content without discarding intrinsic maxima +ok overlay retains baseline identity and explicit clean overflow truth +ok identical overlay inputs produce identical layout identity +ok RTL swaps logical start and end while preserving physical padding truth +ok layout identity records direction even when other overlay inputs match +ok unbounded overlay container derives exact preferred size from padding and insets +ok minimum intrinsic overlay size remains visible as per-axis overflow under pressure +ok overlay reports outer padding overflow even without children +ok duplicate overlay IDs fail at the exact item with owned empty output +ok unsupported overlay baseline alignment fails instead of pretending to align +ok nonmonotonic overlay intrinsics fail before placement +ok unsafe overlay insets fail before bounded arithmetic +ok 253-extent overlay sweep preserves exact padding and stretch bounds +Overlay contract: pass=17 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/grid-contract (148410 bytes main, 0 external tools) +ok grid resolves fixed and weighted fraction tracks against exact available width +ok grid auto placement is deterministic row-major and preserves input identity +ok grid spans include interior gaps and retained z-order without geometry ambiguity +ok grid exposes clean per-axis overflow and stable nonzero layout identity +ok identical grid inputs produce identical track and placement identity +ok spanning intrinsic content grows eligible tracks and determines an unbounded container +ok RTL mirrors logical grid columns and records direction in deterministic identity +ok single-row baseline alignment preserves typography across unequal glyph boxes +ok explicit grid collisions fail at the exact item by default +ok explicit overlap is opt-in and reports every occupied collision cell +ok duplicate grid IDs fail before placement +ok auto placement fails closed when no unoccupied span remains +ok partial auto coordinates are rejected instead of guessed +ok zero-weight fraction tracks fail typed validation +ok unshrinkable fixed tracks remain exact and expose quantified overflow +ok 122-extent fraction sweep fills exact padded bounds without drift or hidden overflow +Grid contract: pass=16 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/scroll-virtual-contract (95654 bytes main, 0 external tools) +ok scroll consumes exact in-bounds deltas and advances one revision +ok scroll exposes unconsumed boundary deltas for nested gesture handoff +ok boundary pressure performs no phantom state revision +ok identical scroll inputs produce identical mutation and retained-state identity +ok nearest reveal moves only enough to expose the complete target +ok center reveal resolves both axes against the authoritative viewport +ok logical horizontal scroll start mirrors to the physical RTL content edge +ok content reconciliation preserves an explicit anchor viewport position +ok identical anchored reconciliation stays completely idle +ok malformed scroll state fails before mutation +ok malformed reveal geometry fails closed +ok million-row layout finds the exact semantic visible range without a linear scan +ok million-row virtualization keeps live nodes and examined work independently bounded +ok virtual placement retains stable IDs and exact viewport-local geometry +ok identical million-row inputs produce bit-identical virtual layout identity +ok sparse variable extents preserve stable item identity and measured height revisions +ok anchor reconciliation absorbs size changes above the viewport without visible jumps +ok extent revisions and changed geometry alter deterministic virtual identity +ok horizontal virtualization mirrors logical item order under RTL +ok empty virtual collections retain padding and canonical empty semantic ranges +ok a viewport containing only leading padding reports no phantom visible item +ok live-node ceilings fail before unbounded viewport allocation +ok unsorted sparse extent overrides fail at the exact record +ok billion-row content extent overflow fails before multiplication or allocation +ok out-of-contract scroll coordinates fail with an exact typed reason +ok 28-position virtual sweep preserves stable IDs exact anchors and logarithmic bounded work +Scroll/virtual contract: pass=26 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/virtual-collections-contract (155429 bytes main, 0 external tools) +ok million-row Table projects the exact semantic row range without materializing the collection +ok Table bounds two-axis live cells while retaining pinned and overscanned columns +ok pinned headers and rows remain fixed while the body scrolls to a stable row ID +ok scrolling columns resolve exact logical offsets and maximum horizontal range +ok identical Table inputs produce identical cell identity and geometry +ok RTL mirrors pinned and scrolling Table columns without changing semantic order +ok Table resize exposes clamped consumption and advances exact model and column revisions +ok nonresizable pinned columns reject mutation without losing requested delta truth +ok Table cell residency ceilings fail before cell allocation +ok pinned Table columns must form one deterministic logical-leading region +ok duplicate Table column IDs fail at the exact column before layout +ok Tree projection removes collapsed descendants while preserving source and sibling identity +ok Tree virtualizes the expanded semantic projection with exact visible and live ranges +ok Tree indentation is logical and preserves the full selectable row frame +ok RTL moves Tree indentation to logical start without reordering nodes +ok expansion revisions restore descendants and alter deterministic projection identity +ok expanded Tree placement retains parent identity and original source index +ok duplicate Tree IDs fail before visible projection +ok Tree depth jumps fail at the exact malformed preorder node +ok Tree parent mismatches fail instead of inferring ownership +ok Tree leaves cannot silently own child nodes +ok initial virtual residency allocates one retained instance per live stable ID +ok virtual focus attaches to a retained stable ID +ok offscreen focus remains retained while nonfocused instances recycle within capacity +ok returning focused IDs restore the exact retained instance without generation drift +ok identical live residency performs no lifecycle revision or identity churn +ok duplicate live IDs fail before retained-store mutation +ok offscreen focus consumes explicit capacity instead of being silently recycled +ok Table scroll 4096-node Tree expansion and 100-window recycling sweeps preserve bounded stable identity +Virtual collections contract: pass=29 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/state-reconcile-contract (33433 bytes main, 0 external tools) +ok state read returns the typed value +ok duplicate reads collapse to one exact dependency +ok state change records exact revisions +ok the recorded reader invalidates for its state change +ok invalidation names the exact read, reader, and revision edge +ok unread state does not invalidate the reader +ok binding reads and writes the target state +ok binding emits an auditable action +ok a fresh read is not invalidated by an older change +ok environment miss uses its explicit fallback +ok environment revisions change only when values change +ok environment returns the latest value +ok reconciliation reports reuse, update, and insertion exactly +ok stable keys preserve identity through reorder +ok fingerprint change advances only the updated node generation +ok kind replacement and missing keys retire old nodes +ok replacement gets a new identity while retained key stays stable +ok duplicate keys fail visibly and deterministically keep the first node +ok 20-permutation sweep preserves identity without phantom work +State/reconcile contract: pass=19 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/semantics-contract (91967 bytes main, 0 external tools) +ok a semantic root is accepted +ok a named actionable child is accepted +ok editable text navigation data is accepted +ok range semantics preserve value and step +ok owned semantic names are queryable by stable ID +ok owned descriptions and exact logical bounds are inspectable +ok semantic actions are explicit capabilities +ok selection and live-region state remain explicit +ok text value and selection remain inspectable +ok range value remains inspectable +ok semantic relationships retain their stable target ID +ok focus order is deterministic rather than insertion based +ok disabled nodes are excluded from focus traversal +ok tree revision advances exactly once per accepted node +ok duplicate IDs fail without mutating the tree +ok reserved sentinel IDs cannot masquerade as semantic nodes +ok missing parents report the exact rejected node +ok duplicate explicit focus order fails visibly +ok malformed range semantics are rejected +ok out-of-bounds text selection is rejected +ok negative semantic geometry is rejected without mutation +ok dangling semantic relationships fail closed +ok collection indices require a positive span +ok noncanonical semantic flags fail instead of leaking ambiguous state +ok virtual collection roots retain full logical row and column counts +ok live cells retain zero-based logical collection coordinates +ok tree level, set position, and expansion truth are explicit +ok tree metadata remains inspectable after ownership transfer +ok generated semantic IDs are deterministic for the same structural input +ok 100 generated sibling IDs remain unique +ok generated IDs preserve parent scope generation +ok semantic transactions can roll back owned nodes and exact revision truth +Semantics contract: pass=32 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/collection-semantics-contract (189668 bytes main, 0 external tools) +ok Table semantics retain full logical counts while materializing only live cells +ok Table semantic residency stays bounded by live layout output +ok Table cells expose stable ID-first coordinates and exact layout geometry +ok Table cell semantic IDs are deterministic from authoritative composite identity +ok Table identity collisions fail during preflight without partial tree mutation +ok fabricated Table cell identity is rejected before semantic mutation +ok Tree semantics retain the full expanded projection count with bounded live nodes +ok Tree items expose one-based level and truthful expansion action +ok Tree semantic position and row bounds follow stable visible identity +ok invalid virtual layouts cannot leak partial semantic nodes +Collection semantics contract: pass=10 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/talkback-contract (121715 bytes main, 0 external tools) +ok discover returns node count and deterministic tree evidence hash +ok ID queries and declared semantic actions are accepted +ok ID queries resolve to an inspectable semantic node +ok Talkback query evidence exposes role, exact bounds, and declared actions +ok Talkback query evidence fingerprints owned text and explicit state +ok responses distinguish emitted actions from read-only queries +ok an ID action absent from semantics fails visibly +ok query and discover evidence hashes are deterministic and retained +ok failures retain deterministic evidence and stay inspectable +ok stale semantic revisions cannot silently target replacement nodes +ok missing ID targets return a structured failure +ok all action-oriented commands are accepted when command capabilities are enabled +ok interactive commands and inspectors emit deterministic evidence hashes +ok action dispatch is recorded in emitted-action evidence +ok inspect and timeline expose deterministic result counts +ok replay exposes retained timeline length as result metadata +ok commands not advertised by capability remain fail-closed +ok invalid timeout policy is rejected before dispatch +ok fail-closed results preserve deterministic evidence +ok accepted and rejected requests share one ordered evidence log +ok pixel fallback is disabled unless the backend advertises it +ok pixel attempts are never reported as ID targeting +ok pixel fallback resolves physical pixels through recorded display scale +ok pixel action emission is independent from ID-action capability +ok scaled pixel targets outside the viewport fail visibly +ok pixel evidence retains coordinates and scale +ok capability reporting is always queryable +ok screenshot remains unsupported until transport capture is implemented +ok snapshot and replay primitives are accepted when supported +ok query helpers expose deterministic result counts +ok the experimental protocol version is explicit +Zagkit Talkback contract: pass=31 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/render-resources-contract (41089 bytes main, 0 external tools) +ok resource store accepts valid typed payloads with explicit metadata +ok owned resources are kept in canonical stable-ID order with exact byte accounting +ok resource payload bytes are copied into store ownership +ok binary lookup resolves exact IDs without fabricating missing resources +ok revisioned replacement preserves identity and updates owned bytes and content truth +ok stale replacement fails before payload mutation +ok resource identity cannot silently change kind during replacement +ok sealed resource stores verify complete owned content identity +ok sealed resource stores reject further mutation +ok out-of-contract payload mutation is detected before rendering +ok insertion order cannot change canonical resource identity +ok canonical stores remain independently verifiable after sealing +ok invalid allocation policy fails before payload ownership +ok nonpositive resource IDs fail visibly +ok empty resource payloads cannot claim an asset +ok new resource IDs must begin at canonical revision zero +ok decoded images require dimensions and color-space truth +ok per-resource byte ceilings fail before copying payloads +ok duplicate stable resource IDs fail before ownership changes +ok total byte ceilings are transactional and preserve prior resources +ok replacement of a missing ID fails without becoming insertion +ok unsealed stores cannot masquerade as immutable render evidence +ok resource-count ceilings fail before allocating a second payload +Render resources contract: pass=23 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/path-contract (50568 bytes main, 0 external tools) +ok line commands require an open contour +ok close commands require an open contour +ok move line and close build a canonical contour +ok closed contours require a new move before geometry +ok coordinates outside the shared fixed-point domain fail before ownership changes +ok valid paths seal with deterministic immutable identity +ok sealed paths reject builder mutation +ok raw command mutation is detected by immutable verification +ok restoring exact command bytes restores path identity +ok empty paths cannot claim renderable identity +ok canonical path encoding has one bounded fixed-width record per command +ok path bytes carry the ZKPATH01 magic +ok valid bytes decode to the requested fill rule and a sealed path +ok round trip preserves signed coordinates and every curve verb +ok round trip preserves deterministic path identity +ok decode and re-encode are byte identical +ok bad path magic fails at byte zero +ok unknown path versions fail visibly +ok unknown fill rules fail before command allocation +ok unknown path verbs fail at the exact command record +ok unused coordinate fields must be canonical zeroes +ok path payload tampering fails deterministic identity verification +ok truncated command data reports the observed length +ok trailing path bytes fail canonical decoding +ok 100-build sweep preserves canonical path identity +Path contract: pass=25 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/image-contract (32414 bytes main, 0 external tools) +ok one RGBA8 texel with explicit sRGB metadata is canonical +ok non-image resources cannot masquerade as decoded pixels +ok unknown decoded-image schemas fail closed +ok RGBA8 payload length must match dimensions exactly +ok decoded images require explicit color-space truth +ok zero dimensions fail before size arithmetic +ok decoded RGBA8 byte accounting is exact +ok invalid dimensions never produce allocation sizes +ok multiplication beyond the hard byte domain fails without overflow +Image contract: pass=9 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/png-encode-contract (196233 bytes main, 0 external tools) +ok one RGBA8 pixel produces the exact canonical PNG size +ok snapshot bytes begin with the PNG signature +ok IHDR declares one by one noninterlaced RGBA8 pixels +ok snapshot color intent is explicitly sRGB perceptual +ok every PNG chunk carries a valid IEEE CRC32 +ok IEND terminates the file with no trailing bytes +ok IDAT decodes to filter zero followed by exact RGBA bytes +ok identical CPU surfaces produce byte-identical PNG snapshots +ok invalid surfaces cannot become screenshot evidence +ok surface byte-count mutation fails before PNG encoding +PNG encode contract: pass=10 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/png-decode-contract (553245 bytes main, 0 external tools) +ok canonical screenshot PNG decodes to exact owned RGBA8 pixels +ok decoded PNG becomes a canonical owned image resource +ok None, Sub, Up, Average, and Paeth filters reconstruct exact bytes +ok decoded PNG resources match CPU goldens at 1x 1.25x 1.5x 2x and 3x +ok packed one-bit grayscale expands and applies transparent gray +ok packed indexed color expands palette entries and per-index alpha +ok RGB16 uses deterministic high-byte conversion and exact tRNS matching +ok grayscale-alpha16 expands to canonical RGBA8 +ok RGBA16 expands each channel with one documented conversion policy +ok declared gamma and standard primaries convert without assumed profile truth +ok gamma without declared primaries keeps the fallback-profile truth visible +ok linear-gamma samples convert deterministically into canonical sRGB +ok meaningless zero gamma is ignored while fallback truth stays visible +ok Display P3 primaries convert through the bounded linear matrix path +ok non-D65 white points use deterministic Bradford adaptation +ok chromaticities without a transfer curve preserve fallback-profile truth +ok explicit sRGB takes precedence over accompanying gamma and chromaticities +ok duplicate color metadata fails before image decompression +ok singular chromaticities fail before decompression or conversion +ok bounded ICC matrix and identity TRCs execute as declared profile truth +ok Display P3 ICC matrix columns convert through the D50 PCS +ok understood iCCP takes precedence over compatibility color chunks +ok ICC tag offsets cannot escape the bounded decompressed profile +ok two-entry sampled ICC curves interpolate the complete input domain +ok multi-entry sampled ICC curves use deterministic linear interpolation +ok 4096 sampled ICC parse decode and cleanup cycles preserve exact output +ok ICC lut16 input tables CLUT and output tables execute in declared order +ok 4096 ICC lut16 parse decode and cleanup cycles preserve exact output +ok ICC lut16 grids above the bounded three dimensional ceiling fail closed +ok RGB to PCS lut16 profiles require the ICC mandated identity matrix +ok ambiguous lut8 PCSXYZ profiles remain an explicit unsupported path +ok ICC lut16 tables must consume exactly their declared bounded tag +ok ICC lut16 one dimensional tables require at least two entries +ok A2B0 LUT execution cannot impersonate a non-perceptual rendering intent +ok ICC mAB A curves nonuniform CLUT and B curves execute in declared order +ok 4096 ICC mAB curve CLUT and cleanup cycles preserve exact output +ok ICC mAB unused CLUT dimensions must remain canonical zeroes +ok ICC mAB A CLUT B execution requires the complete permitted combination +ok ICC mAB embedded curve types fail before CLUT ownership escapes +ok ICC mAB CLUT precision is restricted to declared 8 or 16 bit data +ok ICC mAB CLUT bytes cannot overlap an embedded curve sequence +ok sampled ICC curve counts above the execution ceiling fail before allocation +ok sampled ICC curve tables cannot cross their declared tag bounds +ok iCCP profile names reject leading and consecutive spaces +ok unknown iCCP compression methods fail before profile inflate +ok duplicate iCCP chunks fail before image decompression +ok ICC parametric gamma curves execute through the same linear pipeline +ok ICC type-3 sRGB parametric curves execute both piecewise branches +ok ICC parametric curve types 1 2 and 4 execute their declared equations +ok differing ICC channel TRCs execute independently before matrix conversion +ok unimplemented higher-precedence cICP metadata fails explicitly +ok duplicate required ICC tags fail without ambiguous profile execution +ok ICC declared size must equal the bounded decompressed profile +ok corrupt iCCP zlib streams fail before ICC tag parsing +ok eight full gamma ramps match independent sRGB transfer references +ok all seven Adam7 passes reconstruct exact RGBA8 pixels through every filter +ok 144 Adam7 dimension combinations preserve empty-pass and edge geometry +ok Adam7 deinterlaces packed sub-byte samples without losing bit identity +ok PNG IDAT accepts independently generated dynamic-Huffman zlib streams +ok invalid signatures fail before chunk parsing +ok chunk corruption fails CRC validation before decompression +ok bytes after IEND fail canonical full-file decoding +ok every strict PNG prefix fails without reading beyond available bytes +ok unknown PNG interlace methods fail before decompression +ok chunk type reserved bits fail even when the attacker repairs the CRC +ok oversized dimensions fail before allocation or decompression +ok unknown scanline filters fail explicitly +ok compressed expansion beyond exact scanline size hits the inflate ceiling +ok indexed images require a palette before inflate +ok palette indices outside declared entries fail without out-of-bounds access +PNG decode contract: pass=70 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/png-decode-fuzz (441723 bytes main, 0 external tools) +ok 20000 deterministic arbitrary byte streams remain bounded and owned +ok 20000 structured PNG mutations fail closed or retain canonical output +ok 20000 arbitrary ICC profiles remain bounded and fail closed +ok every strict canonical prefix preserves failure-result ownership invariants +ok 4096 decode and cleanup cycles preserve exact pixels without state leakage +PNG decode fuzz: pass=5 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/render-headless-reference (200921 bytes main, 0 external tools) +Reference snapshot contract: pass=3 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/display-list-contract (123983 bytes main, 0 external tools) +ok display list records a balanced scene +ok display list owns every referenced resource and includes resource revisions +ok paths, images, and glyph runs remain explicit operations +ok layers and effects remain explicit operations +ok identical scenes produce the same deterministic content hash +ok one paint-channel change alters content identity +ok balanced display lists seal immutably +ok sealed content verifies against its deterministic identity +ok sealed display lists reject mutation +ok rejected mutation preserves list content and identity +ok out-of-contract raw mutation is detected before rendering +ok restored sealed content verifies again +ok display verification detects owned resource payload mutation +ok restored resource bytes restore complete display identity +ok unsealed builders cannot claim immutable verification +ok restore underflow fails before mutation +ok layer underflow fails before mutation +ok empty draw geometry fails visibly +ok resource operations require explicit identity +ok layer opacity outside RGBA16 range is rejected +ok raw paint channels outside RGBA16 fail before mutation +ok rectangle strokes require a positive logical width +ok draw-image paint keeps RGB and stroke fields canonical while alpha owns opacity +ok unbalanced save state cannot seal +ok a corrected stack seals without rebuilding the list +ok sealing fails at the exact missing resource reference +ok resource kind mismatches cannot masquerade as drawable payloads +ok canonical path validation rejects opaque owned bytes before operation validation +ok decoded image byte counts are validated before operation references +ok 50-build sweep preserves deterministic operation identity +Display-list contract: pass=30 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/display-list-codec-contract (156447 bytes main, 0 external tools) +ok sealed display list encodes resource records payload bytes and operations to the exact size +ok codec carries the ZKDL magic +ok valid bytes decode to a sealed display list +ok round trip preserves deterministic identity +ok round trip preserves retained ownership +ok round trip preserves signed geometry and RGBA16 paint +ok round trip preserves replacement revision metadata and exact owned payload bytes +ok decode and re-encode are byte identical +ok bad magic fails at byte zero +ok unsupported versions fail visibly +ok unknown operation kinds fail at the exact record +ok content tampering fails deterministic identity verification +ok unknown resource kinds fail at the exact resource record +ok decoded resource kind mismatch fails before the referenced operation can seal +ok invalid resource metadata fails at its record before payload ownership +ok payload lengths cannot cross the declared bounded resource section +ok serialized allocation policy must remain within hard decoder limits +ok resource history revisions must reconstruct exactly before sealing +ok decoded operations cannot reference absent resource IDs +ok malformed typed path payloads fail at their resource payload before operation decoding +ok truncated headers fail with the observed length +ok trailing bytes fail canonical decoding +ok unsealed builders cannot be serialized as immutable replay input +ok 64 payload lengths preserve canonical resource bytes through decode and re-encode +Display-list codec contract: pass=24 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/cpu-raster-contract (232787 bytes main, 0 external tools) +ok sealed fill list rasterizes successfully +ok opaque RGBA16 red resolves exactly to RGBA8 +ok fill bounds include only covered pixels +ok raster evidence counts touched pixels exactly +ok left fractional edge receives exact half coverage +ok right fractional edge receives matching half coverage +ok source-over alpha is deterministic and rounded symmetrically +ok clip and translation compose in fixed-point logical space +ok state operations and clipped work are counted exactly +ok identical sealed lists produce identical CPU pixels +ok centered rectangle strokes rasterize as bounded analytic rings +ok one logical pixel strokes split exact coverage across inner and outer edges +ok stroke corners subtract inner area with deterministic rounding +ok canonical decoded images render through the explicit draw-image operation +ok one-to-one image rendering preserves exact RGBA8 texels including transparency +ok bilinear scaling is symmetric and aligns source and destination pixel centers +ok fractional image edges and operation opacity compose without dark fringes +ok unimplemented color conversion fails before image pixels are touched +ok canonical line paths rasterize as one deterministic display operation +ok 8 by 8 path sampling produces exact symmetric diagonal coverage +ok even-odd fill removes nested contours regardless of matching orientation +ok non-zero fill retains nested contours with matching winding +ok quadratic and cubic flattening produces stable nonempty CPU output +ok path raster work exceeding the deterministic budget fails before touching pixels +ok path rendering participates in the retained transform and clip stack +ok transformed path coverage is clipped at subpixel sample positions +ok unsealed display lists cannot reach the CPU oracle +ok unimplemented layer compositing fails closed at the exact operation +ok invalid surface dimensions fail without allocation claims +ok unsafe transform arithmetic is rejected before multiplication +CPU raster contract: pass=30 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/input-contract (139392 bytes main, 0 external tools) +ok highest z-order clipped node wins overlap +ok local clip excludes the top node without hiding lower content +ok affine inverse returns exact local coordinates for rotated content +ok duplicate hit IDs fail before mutation +ok missing hit parent fails visibly +ok singular transforms never enter the hit tree +ok unsafe transform translation never enters the hit tree +ok unsafe direct hit coordinates fail closed before affine arithmetic +ok capture cannot target a pointer before its down phase +ok pointer down targets top node and advances focus +ok enabled node can capture an active pointer identity +ok captured move remains ID-routed outside bounds with local coordinates +ok pointer up routes to capture then releases it +ok uncaptured outside move has no phantom target +ok input evidence is one monotonic ordered stream +ok invalid pointers fail visibly without escaping the event log +ok focus requests enforce enabled focusable targets +ok disabled capture target releases and reroutes from current truth +ok cancel routes once to capture then clears active pointer state +Input contract: pass=19 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/replay-contract (664961 bytes main, 0 external tools) +ok sealed tape preserves one canonical ordered event stream +ok complete tape applies every event without hidden skips +ok state revisions and monotonic clock replay exactly +ok backend activation loss and recovery remain observable in final truth +ok replay regenerates input motion semantics measurement layout display and CPU identities +ok replayed clock samples advance interruptible motion state +ok replayed state regenerates the expected wrapped Flex line structure +ok two executions of the same sealed tape are bit-identical +ok sealed tape mutation fails before any event is applied +ok unsealed tapes cannot masquerade as replay evidence +ok sealed tapes reject appended events without mutation +ok invalid events fail before tape revision or hash changes +ok unused event fields must stay zero for one canonical tape representation +ok stale state revisions fail at the exact ordered event +ok clock regression fails at the exact ordered event +ok device loss without an active backend fails closed +ok recovery without a recorded loss fails closed +ok backend identity cannot silently change during loss +ok state changes propagate through motion semantics measurement layout display and CPU output +ok state replay crosses Flex wrapping boundaries deterministically +ok pointer differences change input identity without contaminating scene generation +Replay contract: pass=21 fail=0 +znc: wrote native binary /tmp/zagkit-headless.FMDX60/motion-contract (148884 bytes main, 0 external tools) +ok idle clocks perform no motion work and schedule no phantom frame +ok refresh-rate changes update the explicit frame deadline +ok refresh-rate changes retain an observable frame reason +ok invalid display rates fail without mutating scheduler truth +ok fixed-step springs are independent of refresh callback grouping +ok spring integration advances position and velocity without teleporting +ok running motion requests another frame even when a callback repeats the same clock +ok identical supplied clocks produce identical full motion evidence +ok settled springs snap exactly and produce no continuous idle work +ok interruption preserves exact position and incoming velocity +ok reversal preserves exact position and incoming velocity +ok resize retargeting preserves continuity while changing destination +ok gesture handoff transfers position and velocity exactly +ok continuity changes retain ordered semantic frame reasons +ok keyframes interpolate exact position and segment velocity from owned data +ok keyframe direction changes preserve exact deterministic velocity +ok keyframe timelines settle exactly and stop scheduling frames +ok settled timelines remain completely idle +ok reduced motion uses an explicit snap substitution for spatial movement +ok reduced motion can use a caller-authored opacity substitution timeline +ok tracks cannot start without an authoritative supplied clock +ok duplicate stable motion IDs fail before allocation +ok running motion cannot be skipped by advancing the clock outside tick +ok clock regression fails before scheduler or track mutation +ok unbounded suspend jumps fail visibly before expensive catch-up +ok retargeting a missing stable ID fails visibly +ok malformed keyframe order fails before ownership transfer +ok reduced opacity substitution rejects spatial values masquerading as a fade +ok spatial springs require an explicit snap reduced-motion substitution +Motion contract: pass=29 fail=0 +headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths and images, bounded PNG decode, display lists, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion) diff --git a/docs/evidence/headless-launch-2026-08-07-latest.log b/docs/evidence/headless-launch-2026-08-07-latest.log new file mode 100644 index 0000000..21bc6db --- /dev/null +++ b/docs/evidence/headless-launch-2026-08-07-latest.log @@ -0,0 +1,41 @@ +zagkit: running headless-only reference path +zagkit: running headless reference from /home/micah/Desktop/Sylorlabs/zagkit/tools/render-headless-reference.zag +znc: wrote native binary /home/micah/Desktop/Sylorlabs/zagkit/.zagkit/headless-reference (200921 bytes main, 0 external tools) +zagkit: wrote /home/micah/Desktop/Sylorlabs/zagkit/artifacts/launch/headless-reference.png +........................................................................................................................ +........................................................................................................................ +........................................................................................................................ +........................................................................................................................ +........................................................................................................................ +........................................................................................................................ + + .......................................... + ............................ .:........................................:. ..:-------::. + ............................ .:........................................:. =++++++++++++=-. + .....-================-..... .:........................................:. .++++++++++++++++- + .....==================..... .:........................................:. -+++++++++++++++++= + ............................ .:........................................:. =++++++++++++++++++: + ............................ .:........................................:. .+++++++++++++++++++- + .....-------------:......... .:........................................:. :+++++++++++++++++++: + ............................ .:........................................:. =++++++++++++++++++= + ............................ .:........................................:. -+++++++++++++++++=. + .....:---------------:...... .:........................................:. .-++++++++++++++- + ............................ .:........................................:. .-=+++++++==:. + ............................ .......................................... ..::::. + ............................ + ............................ .......................................... ......................... + ............................ ............................................ :+++++++======------:::::-: + ............................ ............................................ :+++++++======------:::::-: + ............................ ............................................ :+++++++======-----:::::::: + ............................ ............................................ :+==========------::::::::: + ............................ ............................................ :==========-----::::::::::. + ............................ ............................................ :=========-----::::........ + ............................ ............................................ :========-----::::......... + ............................ ............................................ :========----::::......... + ............................ ............................................ :========----::::......... + ............................ ............................................ :---------:::::........... + ............................ ............................................ + ............................ ............................................ + ............................ .......................................... + + diff --git a/docs/evidence/headless-launch-help-2026-08-07-latest.log b/docs/evidence/headless-launch-help-2026-08-07-latest.log new file mode 100644 index 0000000..50893c5 --- /dev/null +++ b/docs/evidence/headless-launch-help-2026-08-07-latest.log @@ -0,0 +1,9 @@ +Usage: + ./zagkit init [path] + ./zagkit build [--path ] [--output ] + ./zagkit run [--project ] [--binary ] [--output ] [--headless-only] [--show-ascii] [--open] + ./zagkit test + +Current status: this command only exposes the headless reference and contract +test surface. It does not launch a native desktop, Wayland, macOS, +Windows, iOS, or Android shell yet. diff --git a/docs/evidence/headless-launch-open-2026-08-07-latest.log b/docs/evidence/headless-launch-open-2026-08-07-latest.log new file mode 100644 index 0000000..6c08543 --- /dev/null +++ b/docs/evidence/headless-launch-open-2026-08-07-latest.log @@ -0,0 +1,4 @@ +zagkit: running headless-only reference path +zagkit: running headless reference from /home/micah/Desktop/Sylorlabs/zagkit/tools/render-headless-reference.zag +znc: wrote native binary /home/micah/Desktop/Sylorlabs/zagkit/.zagkit/headless-reference (200921 bytes main, 0 external tools) +zagkit: wrote /home/micah/Desktop/Sylorlabs/zagkit/artifacts/launch/headless-reference.png diff --git a/docs/evidence/visual-direction-matrix-req-existing-2026-08-07-final.log b/docs/evidence/visual-direction-matrix-req-existing-2026-08-07-final.log new file mode 100644 index 0000000..e54a93d --- /dev/null +++ b/docs/evidence/visual-direction-matrix-req-existing-2026-08-07-final.log @@ -0,0 +1,22 @@ +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p0-motion-full-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p0-motion-reduced-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p0-motion-reduced-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p3-motion-full-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p3-motion-full-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p3-motion-reduced-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-1p3-motion-reduced-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-2p0-motion-full-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-2p0-motion-reduced-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-2p0-motion-reduced-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-full-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-full-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-reduced-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-reduced-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-full-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-full-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-reduced-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-reduced-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-2p0-motion-full-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-2p0-motion-full-trans-reduced.png +expected=100800 missing=100368 +FAIL: missing visual-direction captures; run again without --require-existing to preview matrix scope diff --git a/src/automation/talkback.zag b/src/automation/talkback.zag index 2b250fd..3d8ea59 100644 --- a/src/automation/talkback.zag +++ b/src/automation/talkback.zag @@ -332,7 +332,7 @@ fn talkback_emits_action(command: TalkbackCommand) i32 { fn talkback_record(session: *TalkbackSession, tree_revision: i64, request: TalkbackRequest, status: TalkbackStatus) TalkbackResponse { let sequence: i64 = session.*.next_sequence; session.*.next_sequence = session.*.next_sequence + 1; - push[TalkbackEvent](&session.*.events, TalkbackEvent{ + let recorded_event: TalkbackEvent = TalkbackEvent{ .sequence = sequence, .request_id = request.request_id, .command = request.command, @@ -344,7 +344,8 @@ fn talkback_record(session: *TalkbackSession, tree_revision: i64, request: Talkb .pixel_y = request.pixel_y, .scale_numerator = request.scale_numerator, .scale_denominator = request.scale_denominator, - }); + }; + push[TalkbackEvent](&session.*.events, recorded_event); return TalkbackResponse{ .request_id = request.request_id, .status = status, @@ -369,7 +370,7 @@ fn talkback_record(session: *TalkbackSession, tree_revision: i64, request: Talkb .description_length = 0, .value_length = 0, .state_flags = 0, - .evidence_hash = 0, + .evidence_hash = talkback_event_hash(recorded_event), }; } diff --git a/tests/talkback_contract.zag b/tests/talkback_contract.zag index 9cf903d..3a51fa1 100644 --- a/tests/talkback_contract.zag +++ b/tests/talkback_contract.zag @@ -42,6 +42,8 @@ fn id_target_contract(state: *TestState) void { let type_button: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(5, TalkbackCommand.type_text, node_key(2), tree.revision)); expect(state, discover.status == TalkbackStatus.accepted && discover.result_count == 3 && discover.evidence_hash != 0, "discover returns node count and deterministic tree evidence hash"); + let stale: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(6, TalkbackCommand.click, node_key(2), tree.revision - 1)); + let missing: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(7, TalkbackCommand.click, node_key(99), tree.revision)); expect(state, query.status == TalkbackStatus.accepted && click.status == TalkbackStatus.accepted && type_field.status == TalkbackStatus.accepted, "ID queries and declared semantic actions are accepted"); expect(state, query.resolved_node_index == semantics_find_index(tree, node_key(2)) && query.result_count == 1, "ID queries resolve to an inspectable semantic node"); expect(state, query.resolved_role == SemanticRole.button && query.has_bounds == 1 && @@ -55,8 +57,8 @@ fn id_target_contract(state: *TestState) void { expect(state, type_button.status == TalkbackStatus.action_unavailable, "an ID action absent from semantics fails visibly"); expect(state, query.evidence_hash != 0 && discover.evidence_hash != 0, "query and discover evidence hashes are deterministic and retained"); - let stale: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(6, TalkbackCommand.click, node_key(2), tree.revision - 1)); - let missing: TalkbackResponse = talkback_dispatch(&session, tree, viewport, talkback_request(7, TalkbackCommand.click, node_key(99), tree.revision)); + expect(state, stale.evidence_hash != 0 && missing.evidence_hash != 0, + "failures retain deterministic evidence and stay inspectable"); expect(state, stale.status == TalkbackStatus.stale_semantics, "stale semantic revisions cannot silently target replacement nodes"); expect(state, missing.status == TalkbackStatus.target_not_found, "missing ID targets return a structured failure"); @@ -90,6 +92,7 @@ fn id_target_contract(state: *TestState) void { expect(state, replay.row_count == 13, "replay exposes retained timeline length as result metadata"); expect(state, unsupported.status == TalkbackStatus.unsupported_command, "commands not advertised by capability remain fail-closed"); expect(state, bad_timeout.status == TalkbackStatus.invalid_timeout, "invalid timeout policy is rejected before dispatch"); + expect(state, unsupported.evidence_hash != 0 && bad_timeout.evidence_hash != 0, "fail-closed results preserve deterministic evidence"); expect(state, session.events.len == 17 && session.events.data[16].sequence == 17, "accepted and rejected requests share one ordered evidence log"); talkback_session_free(&session); diff --git a/tools/test-zagkit-cli.sh b/tools/test-zagkit-cli.sh index 51bbe59..2cb761c 100755 --- a/tools/test-zagkit-cli.sh +++ b/tools/test-zagkit-cli.sh @@ -6,8 +6,23 @@ ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) TMP_ROOT=$(mktemp -d /tmp/zagkit-cli-smoke.XXXXXX) BIN_PATH="$TMP_ROOT/headless-ref" PNG_PATH="$TMP_ROOT/headless-reference.png" +SCAFFOLD_DIR="$TMP_ROOT/project" +PROJECT_BIN="$TMP_ROOT/project.bin" +PROJECT_PNG="$TMP_ROOT/project.png" printf 'zagkit-cli-smoke: using temporary workspace %s\n' "$TMP_ROOT" +printf 'zagkit-cli-smoke: scaffolding a sample project\n' +"$ROOT_DIR/zagkit" init "$SCAFFOLD_DIR" +if [ ! -f "$SCAFFOLD_DIR/zag.mod" ] || [ ! -f "$SCAFFOLD_DIR/src/main.zag" ] || [ ! -f "$SCAFFOLD_DIR/.gitignore" ]; then + echo 'zagkit-cli-smoke: scaffold did not emit required files' + exit 1 +fi + +# Replace scaffolded source with a runnable scene so project mode can be +# validated without changing project initialization defaults. +cp "$ROOT_DIR/tools/render-headless-reference.zag" "$SCAFFOLD_DIR/src/main.zag" +sed -i "s#@import(\\\"../src/render/png_encode.zag\\\")#@import(\\\"$ROOT_DIR/src/render/png_encode.zag\\\")#" "$SCAFFOLD_DIR/src/main.zag" + printf 'zagkit-cli-smoke: checking command help output\n' "$ROOT_DIR/zagkit" --help >/tmp/zagkit-cli-help.log 2>&1 || { cat /tmp/zagkit-cli-help.log @@ -29,6 +44,20 @@ fi printf 'zagkit-cli-smoke: running headless reference command\n' "$ROOT_DIR/zagkit" run --headless-only --binary "$BIN_PATH" --output "$PNG_PATH" >/tmp/zagkit-cli-run.log 2>&1 +printf 'zagkit-cli-smoke: building sample project\n' +"$ROOT_DIR/zagkit" build --path "$SCAFFOLD_DIR" --output "$PROJECT_BIN" >/tmp/zagkit-cli-project-build.log 2>&1 +if [ ! -x "$PROJECT_BIN" ]; then + echo 'zagkit-cli-smoke: expected sample project binary to be created' + exit 1 +fi + +printf 'zagkit-cli-smoke: running sample project with project path\n' +"$ROOT_DIR/zagkit" run --headless-only --project "$SCAFFOLD_DIR" --output "$PROJECT_PNG" >/tmp/zagkit-cli-project-run.log 2>&1 +if [ ! -s "$PROJECT_PNG" ]; then + echo 'zagkit-cli-smoke: expected sample project output PNG to be generated' + exit 1 +fi + if [ ! -s "$PNG_PATH" ]; then echo 'zagkit-cli-smoke: expected output PNG to be generated' echo '--- zagkit run log ---' diff --git a/zagkit b/zagkit index 6832410..808416c 100755 --- a/zagkit +++ b/zagkit @@ -14,8 +14,8 @@ usage() { cat <<'EOF' Usage: ./zagkit init [path] - ./zagkit build [--output ] - ./zagkit run [--binary ] [--output ] [--headless-only] [--show-ascii] [--open] + ./zagkit build [--path ] [--output ] + ./zagkit run [--project ] [--binary ] [--output ] [--headless-only] [--show-ascii] [--open] ./zagkit test Current status: this command only exposes the headless reference and contract @@ -44,32 +44,57 @@ require_toolchain() { } run_headless_reference() { - local binary output + local binary output source_file do_compile binary="${1}" output="${2}" - printf 'zagkit: building headless reference with %s\n' "$ZAG_BIN" + source_file="${3:-$HEADLESS_REFERENCE}" + do_compile="${4:-0}" + printf 'zagkit: running headless reference from %s\n' "$source_file" mkdir -p "$BUILD_DIR" "$LAUNCH_DIR" - "$ZAG_BIN" "$HEADLESS_REFERENCE" --no-zagd --analyze-strict --no-foreground-cache -o "$binary" + if [ "$do_compile" -eq 1 ] && [ "$source_file" != "" ]; then + "$ZAG_BIN" "$source_file" --no-zagd --analyze-strict --no-foreground-cache -o "$binary" + fi "$binary" "$output" } +resolve_project_dir() { + local input="${1:-.}" + if ! cd "$input" 2>/dev/null; then + printf 'zagkit: invalid project path: %s\n' "$input" >&2 + exit 2 + fi + printf '%s\n' "$PWD" +} + +ensure_project_dir() { + local input="${1:-.}" + mkdir -p "$input" + if ! cd "$input" 2>/dev/null; then + printf 'zagkit: invalid project path: %s\n' "$input" >&2 + exit 2 + fi + printf '%s\n' "$PWD" +} + build_headless_reference() { - local output="$1" + local source_file="$1" + local output="$2" require_toolchain mkdir -p "$BUILD_DIR" - printf 'zagkit: building headless reference binary to %s\n' "$output" - "$ZAG_BIN" "$HEADLESS_REFERENCE" --no-zagd --analyze-strict --no-foreground-cache -o "$output" + printf 'zagkit: building %s to %s\n' "$source_file" "$output" + "$ZAG_BIN" "$source_file" --no-zagd --analyze-strict --no-foreground-cache -o "$output" printf '%s\n' "$output" } cmd_init() { local target="${1:-.}" - target=$(CDPATH= cd -- "$target" && pwd) + target=$(ensure_project_dir "$target") mkdir -p "$target/src" "$target/.zagkit" if [ ! -f "$target/zag.mod" ]; then cat > "$target/zag.mod" <<'EOF' name = "zagkit-app" -version = "0.0.0" +version = "0.1.0-experimental.0" +edition = "1.0" main = "src/main.zag" EOF fi @@ -77,11 +102,30 @@ EOF cat > "$target/src/main.zag" <<'EOF' // Placeholder Zag app entrypoint. // This repository currently supports headless reference rendering only. +// Replace this with your app scene once your project bootstrap is in place. fn main() i32 { _zag_eprintln("zagkit: add your app entrypoint scene here"); return 0; } +EOF + fi + if [ ! -f "$target/.gitignore" ]; then + cat > "$target/.gitignore" <<'EOF' +.zagkit/ +artifacts/ +*.zwg +EOF + fi + if [ ! -f "$target/README.md" ]; then + cat > "$target/README.md" <<'EOF' +# Zagkit app + +This project was scaffolded with `./zagkit init`. + +- `./zagkit build --output ` builds a headless binary. +- `./zagkit run --headless-only --binary --output ` renders a deterministic PNG. +- `./zagkit test` runs headless contracts. EOF fi printf 'zagkit: initialized project scaffold at %s\n' "$target" @@ -90,19 +134,19 @@ EOF cmd_build() { local output="$REF_BINARY" + local source_file="$HEADLESS_REFERENCE" local arg if [ $# -eq 0 ]; then - build_headless_reference "$output" + build_headless_reference "$source_file" "$output" return fi - if [ "$1" != "--output" ] && [ "$1" != "help" ] && [ "$1" != "-h" ] && [ "$1" != "--help" ]; then - printf 'zagkit build: unknown argument: %s\n' "$1" >&2 - usage - exit 2 - fi while [ $# -gt 0 ]; do arg="$1"; shift case "$arg" in + --path) + [ $# -ge 1 ] || { printf "zagkit build: --path needs a path\n" >&2; exit 2; } + source_file="$(resolve_project_dir "$1")/src/main.zag" + shift ;; --output) [ $# -ge 1 ] || { printf "zagkit build: --output needs a path\n" >&2; exit 2; } output="$1"; shift;; @@ -117,7 +161,11 @@ cmd_build() { ;; esac done - build_headless_reference "$output" + if [ ! -f "$source_file" ]; then + printf 'zagkit build: source missing for project root build: %s\n' "$source_file" >&2 + exit 2 + fi + build_headless_reference "$source_file" "$output" } cmd_test() { @@ -127,15 +175,25 @@ cmd_test() { cmd_run() { local binary="$REF_BINARY" local output="$REF_IMAGE" + local project_root="" + local explicit_binary=0 local headless_only=1 local show_ascii=0 local open_output=0 + local source_file="" + local run_source_file="$HEADLESS_REFERENCE" + local compile_reference=1 local arg while [ $# -gt 0 ]; do arg="$1"; shift case "$arg" in + --project) + [ $# -ge 1 ] || { printf "zagkit run: --project needs a path\n" >&2; exit 2; } + project_root="$(resolve_project_dir "$1")" + shift ;; --binary) [ $# -ge 1 ] || { printf "zagkit run: --binary needs a path\n" >&2; exit 2; } + explicit_binary=1 binary="$1"; shift;; --output) [ $# -ge 1 ] || { printf "zagkit run: --output needs a path\n" >&2; exit 2; } @@ -154,8 +212,28 @@ cmd_run() { done if [ "$headless_only" -eq 1 ]; then + if [ -n "$project_root" ]; then + source_file="$project_root/src/main.zag" + if [ ! -f "$source_file" ]; then + printf 'zagkit run: project source missing: %s\n' "$source_file" >&2 + exit 2 + fi + if [ "$explicit_binary" -eq 1 ]; then + printf 'zagkit run: --binary is ignored when --project is provided\n' + fi + binary="$BUILD_DIR/headless-project-reference" + build_headless_reference "$source_file" "$binary" >/dev/null + run_source_file="$source_file" + compile_reference=0 + elif [ "$explicit_binary" -eq 1 ]; then + if [ ! -x "$binary" ]; then + printf 'zagkit run: binary missing: %s\n' "$binary" >&2 + exit 2 + fi + compile_reference=0 + fi printf 'zagkit: running headless-only reference path\n' - run_headless_reference "$binary" "$output" + run_headless_reference "$binary" "$output" "$run_source_file" "$compile_reference" printf 'zagkit: wrote %s\n' "$output" if [ "$show_ascii" -eq 1 ]; then if ! require_pillow; then From d3fa2ef94bed7132d0b35c3c6413f04f3fbf0d47 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 20:17:29 -0700 Subject: [PATCH 060/103] Add visual-direction matrix and PrismStudio migration tooling --- .../prismstudio-migration-inventory.json | 1172 +++++++++++++++++ docs/evidence/goal-progress-2026-08-07.md | 25 +- tools/generate-prismstudio-migration-map.sh | 396 ++++++ tools/generate-visual-direction-matrix.sh | 311 +++++ tools/generate-visual-direction-pilot.sh | 10 + 5 files changed, 1907 insertions(+), 7 deletions(-) create mode 100644 contracts/prismstudio-migration-inventory.json create mode 100755 tools/generate-prismstudio-migration-map.sh create mode 100755 tools/generate-visual-direction-matrix.sh diff --git a/contracts/prismstudio-migration-inventory.json b/contracts/prismstudio-migration-inventory.json new file mode 100644 index 0000000..371d88d --- /dev/null +++ b/contracts/prismstudio-migration-inventory.json @@ -0,0 +1,1172 @@ +{ + "source": { + "prismstudio_root": "/home/micah/Desktop/Sylorlabs/PrismStudio", + "inventory_module_count": 49, + "manifest_sections": 5, + "manifest_section_declared_total": 128, + "manifest_probe_total": 129, + "mcp_tool_count": 92, + "command_id_count": 46, + "workspace_sections": 11, + "hard_rules_count": 6 + }, + "migration_scope": { + "source_modules": [ + { + "name": "agent.zag", + "role": "headless agent RPC for CLI and MCP wrappers." + }, + { + "name": "app.zag", + "role": "application core: state, zones, tools, input, and the command" + }, + { + "name": "builder.zag", + "role": "Engine Builder / Density Synthesizer (Masterplan \u00a722)." + }, + { + "name": "capability.zag", + "role": "explicit local-agent grants and append-only request audit." + }, + { + "name": "commands.zag", + "role": "stable command-id registry for palette + agent dispatch." + }, + { + "name": "components.zag", + "role": "the photonic hardware library." + }, + { + "name": "demo.zag", + "role": "reference photonic processor design (shared by GUI + agent)." + }, + { + "name": "device_model.zag", + "role": "versioned physical assumptions with explicit provenance." + }, + { + "name": "editops.zag", + "role": "editing operations above the raw scene: move with waveguide" + }, + { + "name": "export.zag", + "role": "deterministic human/machine-readable project artifacts." + }, + { + "name": "fb.zag", + "role": "software framebuffer for Zag Photonics Architect." + }, + { + "name": "flash_ir.zag", + "role": "Flash FIR v1 importer and photonic execution verifier." + }, + { + "name": "fontdata.zag", + "role": "5x9 bitmap font rows (bits 4..0)" + }, + { + "name": "fontatlas.zag", + "role": "glyph atlas packer: bakes fontdata rows into a cacheable" + }, + { + "name": "gpu_backend.zag", + "role": "User-selectable auto/CPU/virtual/physical backend policy; explicit CPU/virtual choices do not open or probe DRM." + }, + { + "name": "gpu_compute.zag", + "role": "high-level GPU compute operations built on the verified" + }, + { + "name": "gpu_isa_display.zag", + "role": "Compiler-ISA-executed virtual framebuffer with owned memory, ordered fences, complete-frame presentation, and no DRM access." + }, + { + "name": "gpu_isa_raster.zag", + "role": "Strict virtual GFX10.1 tiled raster execution for compiler-emitted clear, depth, clipping, and fixed-point alpha compositing, with CPU shadow fallback." + }, + { + "name": "gpu_raster.zag", + "role": "Compiler-bundle-gated tiled raster software model: clear, geometry, depth, clipping, compositing, per-tile fences, shadow comparison, double buffering, fallback, and mismatch input/image/diff/log/tuple evidence." + }, + { + "name": "gpu_virtual_cert.zag", + "role": "Host-contained certification runner: 10,000 fills, one million actual VM submissions, exact ownership/fences, 86,400 logical soak ticks, raster differentials, and explicitly non-physical evidence." + }, + { + "name": "gpu_rt.zag", + "role": "a pure-Zag AMDGPU runtime. No libc, no libdrm, no Mesa:" + }, + { + "name": "io_chunks.zag", + "role": "chunked design I/O keyed by world 32\u00b3 chunks." + }, + { + "name": "ioline.zag", + "role": "buffered stdin reads (chunked, not byte-per-syscall)." + }, + { + "name": "limits.zag", + "role": "centralized, named, documented resource ceilings for untrusted" + }, + { + "name": "main.zag", + "role": "photonic CPU designer entry point. 100% Zag, no C anywhere." + }, + { + "name": "math3d.zag", + "role": "vectors, orbit camera, projection, and picking rays." + }, + { + "name": "mcp.zag", + "role": "native MCP stdio server (JSON-RPC + Content-Length framing)." + }, + { + "name": "optimizer.zag", + "role": "Continuous Optical-Computation Optimizer (Photon Solver)." + }, + { + "name": "process_stack.zag", + "role": "physical process-stack layer model (plate, part, guide heights)." + }, + { + "name": "rdna.zag", + "role": "a hand-written RDNA1 (GFX10.1) machine-code emitter, in pure Zag." + }, + { + "name": "routing.zag", + "role": "waveguide routing engine. A* over the free voxel lattice," + }, + { + "name": "scene.zag", + "role": "the design database. The scene is stored as (a) a voxel" + }, + { + "name": "session.zag", + "role": "live shared design: one .zpa for GUI + agent + MCP." + }, + { + "name": "sim.zag", + "role": "wave-state simulation. The scene's directed optical graph is" + }, + { + "name": "sim_region.zag", + "role": "incremental simulation recompile." + }, + { + "name": "strutil.zag", + "role": "leak-free string helpers (s2..s6, fmt_i, fmt_f1) for ownership." + }, + { + "name": "ternary.zag", + "role": "balanced ternary optical logic." + }, + { + "name": "tiles.zag", + "role": "tile-based render cache for the 3D viewport." + }, + { + "name": "timing.zag", + "role": "static timing analysis over the routed photonic fabric (Masterplan" + }, + { + "name": "ui.zag", + "role": "dark workbench theme + minimal immediate-mode widgets over fb.zag." + }, + { + "name": "uilayer.zag", + "role": "retained UI panel surfaces: cached pixels + state hashes (Section 3.12)." + }, + { + "name": "viewport.zag", + "role": "the 3D viewport. Software-rasterized in Zag:" + }, + { + "name": "voxel.zag", + "role": "integer lattice coordinates for the design grid." + }, + { + "name": "workspace.zag", + "role": "panel rendering + per-frame orchestration." + }, + { + "name": "workspace_menu.zag", + "role": "menu bar, command palette, status bar, context menu." + }, + { + "name": "workspace_opt.zag", + "role": "optimizer proposal panel + builder panel + apply paths." + }, + { + "name": "workspace_settings.zag", + "role": "modal dialogs, Flash FIR workspace, model browser, settings." + }, + { + "name": "world.zag", + "role": "sparse chunk-based spatial index for million-scale scenes." + }, + { + "name": "x11.zag", + "role": "a pure-Zag X11 client. No libc, no Xlib, no C anywhere:" + } + ], + "probe_manifest_sections": [ + { + "heading": "Production tests \u2014 gated by `tools/verify.zag`", + "declared": 94, + "probes": [ + "agent.zag", + "ast.zag", + "bounds_test.zag", + "boxselect_test.zag", + "builder_test.zag", + "camera_test.zag", + "components.zag", + "copypaste_test.zag", + "crash_recovery_test.zag", + "demo.zag", + "design_db_test.zag", + "dpi_test.zag", + "drc_test.zag", + "engine_test.zag", + "fb.zag", + "fb_bounds_test.zag", + "feedback_test.zag", + "frame_diff_test.zag", + "frame_overlay_test.zag", + "gizmo_test.zag", + "gpu_backend_choice_test.zag", + "gpu_kernel_test.zag", + "gpu_isa_vgpu_test.zag", + "gpu_isa_display_test.zag", + "gpu_isa_raster_test.zag", + "gpu_query_test.zag", + "gpu_promotion_test.zag", + "gpu_raster_shadow_test.zag", + "gpu_raster_pipeline_test.zag", + "gpu_safety_test.zag", + "gpu_uapi_test.zag", + "gpu_vgpu_test.zag", + "gpu_virtual_certification_test.zag", + "inspector_test.zag", + "json.zag", + "lifecycle_test.zag", + "model_schema_test.zag", + "limits_test.zag", + "list.zag", + "main.zag", + "masterplan_evidence_test.zag", + "opt_report_test.zag", + "opt_families_test.zag", + "opt_schedule_test.zag", + "opt_soak_test.zag", + "opt_ui_test.zag", + "opt_verify_test.zag", + "optimizer_test.zag", + "ortho_test.zag", + "outline_test.zag", + "palette_test.zag", + "parser_corpus_test.zag", + "persistence_test.zag", + "picking_test.zag", + "process_stack_test.zag", + "protocol_parser_test.zag", + "provenance_test.zag", + "provenance_units_test.zag", + "reference_pcu_ui_test.zag", + "recovery_ui_test.zag", + "reference_tamper_test.zag", + "render_golden_test.zag", + "render_test.zag", + "robustness_test.zag", + "routing.zag", + "routing_test.zag", + "rt.zag", + "scene.zag", + "scheduling_test.zag", + "security_regression_test.zag", + "session_conflict_test.zag", + "session_presentation_test.zag", + "shortcuts_test.zag", + "sim.zag", + "sim_semantics_test.zag", + "simulation_property_test.zag", + "soak_test.zag", + "stepping_test.zag", + "timeline_test.zag", + "timing_test.zag", + "ui.zag", + "ui_accessibility_test.zag", + "ui_agent_access_test.zag", + "layout_test.zag", + "ui_interactions_test.zag", + "ui_layers_test.zag", + "ui_perf_test.zag", + "ui_prefs_test.zag", + "ui_states_test.zag", + "ui_tokens_test.zag", + "units_test.zag", + "world.zag", + "x11.zag", + "x11_pixel_pack_test.zag", + "znc.zag" + ] + }, + { + "heading": "Hardware-only \u2014 require a real (ideally non-display) GPU; excluded from the safe suite", + "declared": 6, + "probes": [ + "gpu_compute_test.zag", + "gpu_fill_test.zag", + "gpu_parallel_test.zag", + "gpu_submit_test.zag", + "gpu_test.zag", + "gpu_wg_test.zag" + ] + }, + { + "heading": "Dev benchmarks \u2014 timing tools, not pass/fail gates (superseded by `tools/bench.zag`)", + "declared": 2, + "probes": [ + "perf_test.zag", + "scale_test.zag" + ] + }, + { + "heading": "Compiler probes \u2014 exercise a `znc` language feature, not PrismStudio", + "declared": 2, + "probes": [ + "_repro_znc1.zag", + "break_test.zag" + ] + }, + { + "heading": "Obsolete \u2014 early debug/exploration scratch; kept for history, not run and not production", + "declared": 24, + "probes": [ + "agent_place2.zag", + "agent_twoline.zag", + "app.zag", + "components_only.zag", + "hashmap.zag", + "hm_dbg.zag", + "io_chunks.zag", + "lex.zag", + "move_dbg.zag", + "opt_ui_shot.zag", + "probe3.zag", + "probe4.zag", + "route_dbg.zag", + "rsz2.zag", + "scene_only.zag", + "ternary.zag", + "tiles.zag", + "transport.zag", + "viewport.zag", + "voxel_only.zag", + "workspace.zag", + "world_only.zag", + "world_smoke.zag", + "x11_paint.zag" + ] + } + ], + "manifest_count_check": [ + { + "heading": "Production tests \u2014 gated by `tools/verify.zag`", + "declared": 94, + "parsed": 95, + "status": "complete", + "probes": [ + "agent.zag", + "ast.zag", + "bounds_test.zag", + "boxselect_test.zag", + "builder_test.zag", + "camera_test.zag", + "components.zag", + "copypaste_test.zag", + "crash_recovery_test.zag", + "demo.zag", + "design_db_test.zag", + "dpi_test.zag", + "drc_test.zag", + "engine_test.zag", + "fb.zag", + "fb_bounds_test.zag", + "feedback_test.zag", + "frame_diff_test.zag", + "frame_overlay_test.zag", + "gizmo_test.zag", + "gpu_backend_choice_test.zag", + "gpu_kernel_test.zag", + "gpu_isa_vgpu_test.zag", + "gpu_isa_display_test.zag", + "gpu_isa_raster_test.zag", + "gpu_query_test.zag", + "gpu_promotion_test.zag", + "gpu_raster_shadow_test.zag", + "gpu_raster_pipeline_test.zag", + "gpu_safety_test.zag", + "gpu_uapi_test.zag", + "gpu_vgpu_test.zag", + "gpu_virtual_certification_test.zag", + "inspector_test.zag", + "json.zag", + "lifecycle_test.zag", + "model_schema_test.zag", + "limits_test.zag", + "list.zag", + "main.zag", + "masterplan_evidence_test.zag", + "opt_report_test.zag", + "opt_families_test.zag", + "opt_schedule_test.zag", + "opt_soak_test.zag", + "opt_ui_test.zag", + "opt_verify_test.zag", + "optimizer_test.zag", + "ortho_test.zag", + "outline_test.zag", + "palette_test.zag", + "parser_corpus_test.zag", + "persistence_test.zag", + "picking_test.zag", + "process_stack_test.zag", + "protocol_parser_test.zag", + "provenance_test.zag", + "provenance_units_test.zag", + "reference_pcu_ui_test.zag", + "recovery_ui_test.zag", + "reference_tamper_test.zag", + "render_golden_test.zag", + "render_test.zag", + "robustness_test.zag", + "routing.zag", + "routing_test.zag", + "rt.zag", + "scene.zag", + "scheduling_test.zag", + "security_regression_test.zag", + "session_conflict_test.zag", + "session_presentation_test.zag", + "shortcuts_test.zag", + "sim.zag", + "sim_semantics_test.zag", + "simulation_property_test.zag", + "soak_test.zag", + "stepping_test.zag", + "timeline_test.zag", + "timing_test.zag", + "ui.zag", + "ui_accessibility_test.zag", + "ui_agent_access_test.zag", + "layout_test.zag", + "ui_interactions_test.zag", + "ui_layers_test.zag", + "ui_perf_test.zag", + "ui_prefs_test.zag", + "ui_states_test.zag", + "ui_tokens_test.zag", + "units_test.zag", + "world.zag", + "x11.zag", + "x11_pixel_pack_test.zag", + "znc.zag" + ] + }, + { + "heading": "Hardware-only \u2014 require a real (ideally non-display) GPU; excluded from the safe suite", + "declared": 6, + "parsed": 6, + "status": "complete", + "probes": [ + "gpu_compute_test.zag", + "gpu_fill_test.zag", + "gpu_parallel_test.zag", + "gpu_submit_test.zag", + "gpu_test.zag", + "gpu_wg_test.zag" + ] + }, + { + "heading": "Dev benchmarks \u2014 timing tools, not pass/fail gates (superseded by `tools/bench.zag`)", + "declared": 2, + "parsed": 2, + "status": "complete", + "probes": [ + "perf_test.zag", + "scale_test.zag" + ] + }, + { + "heading": "Compiler probes \u2014 exercise a `znc` language feature, not PrismStudio", + "declared": 2, + "parsed": 2, + "status": "complete", + "probes": [ + "_repro_znc1.zag", + "break_test.zag" + ] + }, + { + "heading": "Obsolete \u2014 early debug/exploration scratch; kept for history, not run and not production", + "declared": 24, + "parsed": 24, + "status": "complete", + "probes": [ + "agent_place2.zag", + "agent_twoline.zag", + "app.zag", + "components_only.zag", + "hashmap.zag", + "hm_dbg.zag", + "io_chunks.zag", + "lex.zag", + "move_dbg.zag", + "opt_ui_shot.zag", + "probe3.zag", + "probe4.zag", + "route_dbg.zag", + "rsz2.zag", + "scene_only.zag", + "ternary.zag", + "tiles.zag", + "transport.zag", + "viewport.zag", + "voxel_only.zag", + "workspace.zag", + "world_only.zag", + "world_smoke.zag", + "x11_paint.zag" + ] + } + ], + "mcp_protocol_tools": [ + { + "name": "prismstudio_command", + "mutation": true + }, + { + "name": "prismstudio_mutate", + "mutation": false + }, + { + "name": "prismstudio_help", + "mutation": false + }, + { + "name": "prismstudio_capabilities", + "mutation": false + }, + { + "name": "prismstudio_ping", + "mutation": false + }, + { + "name": "prismstudio_new", + "mutation": false + }, + { + "name": "prismstudio_demo", + "mutation": false + }, + { + "name": "prismstudio_list", + "mutation": false + }, + { + "name": "prismstudio_list_files", + "mutation": false + }, + { + "name": "prismstudio_get", + "mutation": false + }, + { + "name": "prismstudio_select", + "mutation": false + }, + { + "name": "prismstudio_can_place", + "mutation": false + }, + { + "name": "prismstudio_undo", + "mutation": false + }, + { + "name": "prismstudio_redo", + "mutation": false + }, + { + "name": "prismstudio_view", + "mutation": false + }, + { + "name": "prismstudio_camera", + "mutation": false + }, + { + "name": "prismstudio_render_at", + "mutation": false + }, + { + "name": "prismstudio_pick", + "mutation": false + }, + { + "name": "prismstudio_place_here", + "mutation": false + }, + { + "name": "prismstudio_place_on", + "mutation": false + }, + { + "name": "prismstudio_place", + "mutation": false + }, + { + "name": "prismstudio_route", + "mutation": false + }, + { + "name": "prismstudio_delete", + "mutation": false + }, + { + "name": "prismstudio_sim_step", + "mutation": false + }, + { + "name": "prismstudio_sim_state", + "mutation": false + }, + { + "name": "prismstudio_sim", + "mutation": false + }, + { + "name": "prismstudio_move", + "mutation": false + }, + { + "name": "prismstudio_rotate", + "mutation": false + }, + { + "name": "prismstudio_disconnect", + "mutation": false + }, + { + "name": "prismstudio_reroute", + "mutation": false + }, + { + "name": "prismstudio_inspect", + "mutation": false + }, + { + "name": "prismstudio_close", + "mutation": false + }, + { + "name": "prismstudio_build", + "mutation": false + }, + { + "name": "prismstudio_preview", + "mutation": false + }, + { + "name": "prismstudio_simstream", + "mutation": false + }, + { + "name": "prismstudio_timing", + "mutation": false + }, + { + "name": "prismstudio_del", + "mutation": false + }, + { + "name": "prismstudio_sel", + "mutation": false + }, + { + "name": "prismstudio_save", + "mutation": false + }, + { + "name": "prismstudio_open", + "mutation": false + }, + { + "name": "prismstudio_render", + "mutation": false + }, + { + "name": "prismstudio_export", + "mutation": false + }, + { + "name": "prismstudio_diagnostics", + "mutation": false + }, + { + "name": "prismstudio_details", + "mutation": false + }, + { + "name": "prismstudio_audit", + "mutation": false + }, + { + "name": "prismstudio_logs", + "mutation": false + }, + { + "name": "prismstudio_uilog", + "mutation": false + }, + { + "name": "prismstudio_trace", + "mutation": false + }, + { + "name": "prismstudio_flash_import", + "mutation": false + }, + { + "name": "prismstudio_flash_verify", + "mutation": false + }, + { + "name": "prismstudio_ui_list", + "mutation": false + }, + { + "name": "prismstudio_ui_screenshot", + "mutation": false + }, + { + "name": "prismstudio_ui_activate", + "mutation": false + }, + { + "name": "prismstudio_optimizer_apply", + "mutation": false + }, + { + "name": "prismstudio_optimizer_accept", + "mutation": false + }, + { + "name": "prismstudio_optimizer_decline", + "mutation": false + }, + { + "name": "prismstudio_optimizer_ignore", + "mutation": false + }, + { + "name": "prismstudio_optimizer_details", + "mutation": false + }, + { + "name": "prismstudio_builder_generate", + "mutation": false + }, + { + "name": "prismstudio_builder_apply", + "mutation": false + }, + { + "name": "prismstudio_builder_accept", + "mutation": false + }, + { + "name": "prismstudio_builder_decline", + "mutation": false + }, + { + "name": "prismstudio_builder_ignore", + "mutation": false + }, + { + "name": "prismstudio_builder_details", + "mutation": false + }, + { + "name": "prismstudio_copy", + "mutation": false + }, + { + "name": "prismstudio_paste", + "mutation": false + }, + { + "name": "prismstudio_duplicate", + "mutation": false + }, + { + "name": "prismstudio_group", + "mutation": false + }, + { + "name": "prismstudio_ungroup", + "mutation": false + }, + { + "name": "prismstudio_rename", + "mutation": false + }, + { + "name": "prismstudio_toggle_visibility", + "mutation": false + }, + { + "name": "prismstudio_toggle_lock", + "mutation": false + }, + { + "name": "prismstudio_select_add", + "mutation": false + }, + { + "name": "prismstudio_select_remove", + "mutation": false + }, + { + "name": "prismstudio_select_clear", + "mutation": false + }, + { + "name": "prismstudio_view_frame", + "mutation": false + }, + { + "name": "prismstudio_view_toggle", + "mutation": false + }, + { + "name": "prismstudio_panel", + "mutation": false + }, + { + "name": "prismstudio_ui_click", + "mutation": false + }, + { + "name": "prismstudio_ui_type", + "mutation": false + }, + { + "name": "prismstudio_camera_orbit", + "mutation": false + }, + { + "name": "prismstudio_camera_pan", + "mutation": false + }, + { + "name": "prismstudio_camera_zoom", + "mutation": false + }, + { + "name": "prismstudio_model_status", + "mutation": false + }, + { + "name": "prismstudio_model_migrate", + "mutation": false + }, + { + "name": "prismstudio_test", + "mutation": false + }, + { + "name": "prismstudio_status", + "mutation": false + }, + { + "name": "prismstudio_coords", + "mutation": false + }, + { + "name": "prismstudio_process_stack", + "mutation": false + }, + { + "name": "prismstudio_power", + "mutation": false + }, + { + "name": "prismstudio_perf", + "mutation": false + }, + { + "name": "prismstudio_kinds", + "mutation": false + } + ], + "command_ids": [ + { + "name": "cmd_none", + "id": 0 + }, + { + "name": "cmd_new", + "id": 1 + }, + { + "name": "cmd_open", + "id": 2 + }, + { + "name": "cmd_save", + "id": 3 + }, + { + "name": "cmd_save_as", + "id": 4 + }, + { + "name": "cmd_quit", + "id": 5 + }, + { + "name": "cmd_undo", + "id": 10 + }, + { + "name": "cmd_redo", + "id": 11 + }, + { + "name": "cmd_duplicate", + "id": 12 + }, + { + "name": "cmd_delete", + "id": 13 + }, + { + "name": "cmd_deselect", + "id": 14 + }, + { + "name": "cmd_copy", + "id": 15 + }, + { + "name": "cmd_paste", + "id": 16 + }, + { + "name": "cmd_group", + "id": 17 + }, + { + "name": "cmd_ungroup", + "id": 18 + }, + { + "name": "cmd_rename", + "id": 19 + }, + { + "name": "cmd_frame_selected", + "id": 20 + }, + { + "name": "cmd_frame_all", + "id": 21 + }, + { + "name": "cmd_view_top", + "id": 22 + }, + { + "name": "cmd_view_front", + "id": 23 + }, + { + "name": "cmd_view_right", + "id": 24 + }, + { + "name": "cmd_view_perspective", + "id": 25 + }, + { + "name": "cmd_toggle_ortho", + "id": 26 + }, + { + "name": "cmd_cycle_ui_scale", + "id": 27 + }, + { + "name": "cmd_reset_view", + "id": 28 + }, + { + "name": "cmd_section_view", + "id": 29 + }, + { + "name": "cmd_shortcuts", + "id": 30 + }, + { + "name": "cmd_demo", + "id": 31 + }, + { + "name": "cmd_open_flash_reference", + "id": 32 + }, + { + "name": "cmd_model_provenance", + "id": 33 + }, + { + "name": "cmd_recover_autosave", + "id": 34 + }, + { + "name": "cmd_tool_select", + "id": 40 + }, + { + "name": "cmd_tool_route", + "id": 41 + }, + { + "name": "cmd_sim_play_toggle", + "id": 42 + }, + { + "name": "cmd_optimizer", + "id": 43 + }, + { + "name": "cmd_settings", + "id": 44 + }, + { + "name": "cmd_palette", + "id": 45 + }, + { + "name": "cmd_tool_measure", + "id": 46 + }, + { + "name": "cmd_toggle_reduced_motion", + "id": 47 + }, + { + "name": "cmd_toggle_object_snap", + "id": 48 + }, + { + "name": "cmd_flash_workspace", + "id": 49 + }, + { + "name": "cmd_place_base", + "id": 50 + }, + { + "name": "cmd_builder", + "id": 51 + }, + { + "name": "cmd_toggle_visibility", + "id": 70 + }, + { + "name": "cmd_toggle_lock", + "id": 71 + }, + { + "name": "cmd_palette_jump_base", + "id": 1000 + } + ], + "workspace_sections": [ + { + "id": "3.7", + "name": ". A category is a lens, not a partition" + }, + { + "id": "3.2", + "name": "" + }, + { + "id": "3.8", + "name": ", clears after ~3 s" + }, + { + "id": "3.4", + "name": "" + }, + { + "id": "3.9", + "name": "type icon, name (inline-renamable)," + }, + { + "id": "3.6", + "name": "the row becomes a text field" + }, + { + "id": "3.10", + "name": "the row whose beam path is selected in 3D" + }, + { + "id": "3.11", + "name": "" + }, + { + "id": "3.5", + "name": "" + }, + { + "id": "3.12", + "name": "" + }, + { + "id": "20.5", + "name": ". Kept off the interaction path: it" + } + ], + "hard_rules": [ + { + "title": "No hardcoded physical constants" + }, + { + "title": "Fix Zag at the compiler, never work around it in the app" + }, + { + "title": "No committing binaries" + }, + { + "title": "No `CAPS=all` default" + }, + { + "title": "Math constant precision" + }, + { + "title": "String ownership" + } + ] + }, + "open_questions": [ + "No native UI transport exists in this repo for full PrismStudio parity; this inventory proves extraction scope only.", + "No one-shot pixel-to-talking migration suite exists for every command/state pair yet.", + "G6 migration shell replacement and full automation coverage are still unimplemented in Zagkit." + ] +} \ No newline at end of file diff --git a/docs/evidence/goal-progress-2026-08-07.md b/docs/evidence/goal-progress-2026-08-07.md index 96aae04..2dd51a4 100644 --- a/docs/evidence/goal-progress-2026-08-07.md +++ b/docs/evidence/goal-progress-2026-08-07.md @@ -3,7 +3,7 @@ ## Scope - Scope: `/home/micah/Desktop/Sylorlabs/zagkit` - Date: 2026-08-07 -- Note: this workspace is write-restricted for `/home/micah/Desktop/Sylorlabs/zag` and `/home/micah/Desktop/Sylorlabs/PrismStudio`; upstream and migration work remains out of scope in this session. +- Note: this workspace is write-restricted for `/home/micah/Desktop/Sylorlabs/zag`; `/home/micah/Desktop/Sylorlabs/PrismStudio` is read/scan-only from this workspace. Upstream and migration edits remain constrained by this session. ## Verified in this session @@ -11,26 +11,37 @@ - `./tools/check-contracts.sh` - Evidence: [check-contracts](check-contracts-2026-08-07.log) - Evidence (this session): [check-contracts-late](check-contracts-2026-08-07-late5.log) - - Evidence (this continuation): [check-contracts-lateb](check-contracts-2026-08-07-late6b.log), [check-contracts-continued](check-contracts-2026-08-07-final.log) + - Evidence (this continuation): [check-contracts-latest2](check-contracts-2026-08-07-latest2.log), [check-contracts-final](check-contracts-2026-08-07-final.log), [check-contracts-cont2](check-contracts-2026-08-07-cont2.log) - CLI smoke: - `./tools/test-zagkit-cli.sh` - Evidence: [cli smoke](cli-smoke-2026-08-07.log) - Evidence (this session): [cli smoke-late](cli-smoke-2026-08-07-late5.log) - - Evidence (this continuation): [cli smoke-lateb](cli-smoke-2026-08-07-late6b.log), [cli smoke-final](cli-smoke-2026-08-07-final.log) + - Evidence (this continuation): [cli smoke-latest2](cli-smoke-2026-08-07-latest2.log), [cli smoke-final](cli-smoke-2026-08-07-final.log), [cli smoke-cont2](cli-smoke-2026-08-07-cont2.log) - Visual-direction pilot scope: - `./tools/verify-visual-direction-artifacts.sh --mode pilot --exact` - - Evidence: [pilot verification latest](visual-direction-pilot-2026-08-07-late5.log) - - Visual-direction launch: [artifacts completeness check (continued)](visual-direction-matrix-req-existing-2026-08-07-final.log) + - Evidence (this continuation): [pilot generation](visual-direction-pilot-generate-2026-08-07-final.log), [pilot verification latest](visual-direction-pilot-2026-08-07-latest.log) + - Visual-direction launch: [artifacts completeness check (continued)](visual-direction-matrix-req-existing-2026-08-07-latest2.log) - Launch path: - `./zagkit run --headless-only --show-ascii` and `./zagkit --help` - Evidence: [headless launch latest](headless-launch-2026-08-07-latest.log), [headless-launch help](headless-launch-help-2026-08-07-latest.log), [headless launch open attempt](headless-launch-open-2026-08-07-latest.log) + - Evidence this continuation: [headless launch now](headless-launch-2026-08-07-now.log) - Headless core: - `./zagkit test` - Evidence (this session): [headless-contract-latest (new)](headless-contract-latest-2026-08-07-late6.log) - - Evidence (this continuation): [headless-contract-latest (new)](headless-contract-latest-2026-08-07-late7.log), [headless-contract-latest-final](headless-contract-latest-2026-08-07-final.log) + - Evidence (this continuation): [headless test run from Zagkit CLI fresh](headless-contract-latest-2026-08-07-now.log), [headless-contract-latest (new)](headless-contract-latest-2026-08-07-late7.log), [headless-contract-latest-final](headless-contract-latest-2026-08-07-final.log), [headless-contract-latest-final2](headless-contract-latest-2026-08-07-final2.log), [headless test run from Zagkit CLI](headless-contract-latest-2026-08-07-latest2.log) + - Evidence (this continuation): [headless core verification cont2](headless-contract-latest-2026-08-07-cont2.log) +- PrismStudio migration inventory: + - `./tools/generate-prismstudio-migration-map.sh` + - Evidence: [inventory json](../../contracts/prismstudio-migration-inventory.json), [inventory markdown](prismstudio-migration-inventory-2026-08-06.md), [migration evidence log now](prismstudio-migration-inventory-2026-08-07-now.log), [migration evidence log latest](prismstudio-migration-inventory-2026-08-07-latest.log) - Visual-direction generation behavior: - `./tools/generate-visual-direction-pilot.sh` (324 placeholders rendered) - Output location: [artifacts/visual-direction](../../artifacts/visual-direction) +- Visual-direction matrix generation tooling: + - `./tools/generate-visual-direction-matrix.sh --mode pilot --dry-run` + - Evidence: [pilot scope calculation](visual-direction-matrix-pilot-2026-08-07-cont2.log) (expected 324 captures) + - `./tools/generate-visual-direction-matrix.sh --mode full --allow-full --dry-run` + - Evidence: [full scope calculation limited sample](visual-direction-matrix-full-2026-08-07-cont2.log), [full scope count](visual-direction-matrix-full-2026-08-07-cont2b.log) (expected 100,800 captures) + - Note: full mode is gated by `--allow-full` and supports `--max-items` for controlled runs. - Visual-direction completeness gating: - `./tools/visual-direction-matrix-report.sh --require-existing` (reports 100,368 artifacts missing) - Evidence: [matrix completeness check latest](visual-direction-matrix-req-existing-2026-08-07-late2.log) @@ -45,7 +56,7 @@ - `G0-VISUAL-DIRECTION` moved from stalling to evidence-backed pilot scope: - `./tools/generate-visual-direction-pilot.sh` produced the required 324-capture pilot set. - - Evidence: [pilot verification latest](visual-direction-pilot-2026-08-07-late5.log) + - Evidence: [pilot verification latest](visual-direction-pilot-2026-08-07-latest.log) ## Milestone movement diff --git a/tools/generate-prismstudio-migration-map.sh b/tools/generate-prismstudio-migration-map.sh new file mode 100755 index 0000000..7e6a090 --- /dev/null +++ b/tools/generate-prismstudio-migration-map.sh @@ -0,0 +1,396 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_ROOT="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +DEFAULT_PRISM_ROOT="$SCRIPT_ROOT/../PrismStudio" +ARG_ROOT="${1:-}" +if [ -n "${ARG_ROOT}" ]; then + PRISM_ROOT="$ARG_ROOT" +elif [ -d "$DEFAULT_PRISM_ROOT" ]; then + PRISM_ROOT="$DEFAULT_PRISM_ROOT" +elif [ -d "/home/micah/Desktop/Sylorlabs/PrismStudio" ]; then + PRISM_ROOT="/home/micah/Desktop/Sylorlabs/PrismStudio" +else + PRISM_ROOT="" +fi + +if [ -z "$PRISM_ROOT" ]; then + echo "E: PrismStudio root not found. Set with first argument or place it at ../PrismStudio relative to this repo." >&2 + exit 1 +fi +OUTPUT_MD="${2:-docs/evidence/prismstudio-migration-inventory-$(date +%Y-%m-%d).md}" +OUTPUT_JSON="${3:-contracts/prismstudio-migration-inventory.json}" + +if [ ! -d "$PRISM_ROOT" ]; then + echo "E: PrismStudio root missing: $PRISM_ROOT" >&2 + exit 1 +fi + +if [ ! -x "$(command -v python3 || true)" ]; then + echo "E: python3 is required for this generator" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$OUTPUT_MD")" "$(dirname "$OUTPUT_JSON")" + +python3 - "$PRISM_ROOT" "$OUTPUT_MD" "$OUTPUT_JSON" <<'PY' +from __future__ import annotations + +import json +import re +import sys +from collections import OrderedDict +from pathlib import Path + +prism_root = Path(sys.argv[1]) +out_md = Path(sys.argv[2]) +out_json = Path(sys.argv[3]) + +inventory_md = prism_root / "docs" / "INVENTORY.md" +manifest_md = prism_root / "probe" / "MANIFEST.md" +mcp_zag = prism_root / "src" / "mcp.zag" +workspace_zag = prism_root / "src" / "workspace.zag" +commands_zag = prism_root / "src" / "commands.zag" +agents_md = prism_root / "AGENTS.md" + +required_paths = ( + inventory_md, + manifest_md, + mcp_zag, + workspace_zag, + commands_zag, + agents_md, +) +for path in required_paths: + if not path.exists(): + raise SystemExit(f"E: required source missing: {path}") + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def parse_inventory(path: Path): + text = read_text(path) + modules = [] + in_section = False + for line in text.splitlines(): + if line.startswith("## Source modules"): + in_section = True + continue + if not in_section: + continue + if line.startswith("## "): + break + if not line.startswith("|"): + continue + if line.startswith("| Module |") or line.startswith("|---"): + continue + m = re.match(r"^\|\s*`([^`]+)`\s*\|\s*(.*?)\s*\|$", line) + if m: + modules.append({"name": m.group(1).strip(), "role": m.group(2).strip()}) + return modules + + +def parse_manifest(path: Path): + text = read_text(path) + sections = [] + current_heading = None + declared = None + bullets = [] + + for raw in text.splitlines(): + line = raw.strip() + if raw.startswith("## "): + if current_heading is not None: + sections.append( + { + "heading": current_heading, + "declared": declared, + "probes": bullets, + } + ) + heading_match = re.match(r"^##\s+(.+?)\s*$", raw) + if heading_match: + heading_body = heading_match.group(1).strip() + + declared = None + m_declared = re.search(r"\((\d+)\)\s*$", heading_body) + if m_declared is None: + m_declared = re.search(r"\s+[—-]\s+(\d+)\s*$", heading_body) + if m_declared: + declared = int(m_declared.group(1)) + + current_heading = re.sub(r"\s*\(\d+\)\s*$", "", heading_body) + current_heading = re.sub(r"\s+[—-]\s+\d+\s*$", "", current_heading) + current_heading = current_heading.strip() + bullets = [] + continue + + # Non-manifest heading while parsing; stop at next top-level heading. + current_heading = None + declared = None + bullets = [] + continue + + if current_heading is None or not line.startswith("- "): + continue + + m = re.match(r"^- `([^`]+)`\s*$", line) + if m: + bullets.append(m.group(1)) + else: + m = re.match(r"^- (.+)$", line) + if m: + bullets.append(m.group(1).strip()) + + if current_heading is not None: + sections.append( + { + "heading": current_heading, + "declared": declared, + "probes": bullets, + } + ) + + return [ + section for section in sections if section["probes"] or section["declared"] is not None + ] + + +def parse_mcp_tools(path: Path): + text = read_text(path) + tools = [] + seen = set() + pattern = re.compile(r'mcp_append_tool\(&out,\s*(true|false),\s*"([^"]+)"') + for line in text.splitlines(): + m = pattern.search(line) + if m: + key = (m.group(2), m.group(1) == "true") + if key in seen: + continue + tools.append({"name": key[0], "mutation": key[1]}) + seen.add(key) + return tools + + +def parse_command_ids(path: Path): + text = read_text(path) + pattern = re.compile( + r"fn\s+(cmd_[A-Za-z0-9_]+)\s*\(\)\s*i32\s*\{\s*return\s*(-?\d+)\s*;\s*\}" + ) + items = [] + seen = set() + for m in pattern.finditer(text): + name = m.group(1) + value = int(m.group(2)) + key = (name, value) + if key in seen: + continue + items.append({"name": name, "id": value}) + seen.add(key) + return sorted(items, key=lambda item: (item["id"], item["name"])) + + +def parse_workspace_sections(path: Path): + text = read_text(path) + sections = [] + seen = set() + pattern = re.compile(r"Section\s+([0-9]+(?:\.[0-9]+)?)\s*[:\)]?\s*(.*)") + for line in text.splitlines(): + m = pattern.search(line) + if not m: + continue + label = m.group(2).strip(" -—:\u2014").strip() + if label and not re.search(r"[A-Za-z]", label): + continue + section_key = f"section-{m.group(1)}" + if section_key in seen: + continue + seen.add(section_key) + sections.append({ + "id": m.group(1), + "name": label, + }) + return sections + + +def parse_agents_rules(path: Path): + lines = read_text(path).splitlines() + in_rules = False + rules = [] + for line in lines: + if line.startswith("## Hard Rules"): + in_rules = True + continue + if not in_rules: + continue + if re.match(r"^## ", line): + break + m = re.match(r"^###\s+\d+\.\s+(.*)", line) + if m: + rules.append({"title": m.group(1).strip()}) + return rules + + +inventory_modules = parse_inventory(inventory_md) +manifest_sections = parse_manifest(manifest_md) +mcp_tools = parse_mcp_tools(mcp_zag) +workspace_sections = parse_workspace_sections(workspace_zag) +command_ids = parse_command_ids(commands_zag) +hard_rules = parse_agents_rules(agents_md) + +manifest_probe_total = sum(len(section["probes"]) for section in manifest_sections) +declared_total = sum(section["declared"] for section in manifest_sections if section["declared"] is not None) + +manifest_coverage = [] +for section in manifest_sections: + declared = section["declared"] + parsed = len(section["probes"]) + status = "complete" + if declared is None: + status = "partial" + elif parsed < declared: + status = "partial" + manifest_coverage.append({ + "heading": section["heading"], + "declared": declared, + "parsed": parsed, + "status": status, + "probes": section["probes"], + }) + +summary = { + "source": { + "prismstudio_root": str(prism_root), + "inventory_module_count": len(inventory_modules), + "manifest_sections": len(manifest_sections), + "manifest_section_declared_total": declared_total, + "manifest_probe_total": manifest_probe_total, + "mcp_tool_count": len(mcp_tools), + "command_id_count": len(command_ids), + "workspace_sections": len(workspace_sections), + "hard_rules_count": len(hard_rules), + }, + "migration_scope": { + "source_modules": inventory_modules, + "probe_manifest_sections": manifest_sections, + "manifest_count_check": manifest_coverage, + "mcp_protocol_tools": mcp_tools, + "command_ids": command_ids, + "workspace_sections": workspace_sections, + "hard_rules": hard_rules, + }, + "open_questions": [ + "No native UI transport exists in this repo for full PrismStudio parity; this inventory proves extraction scope only.", + "No one-shot pixel-to-talking migration suite exists for every command/state pair yet.", + "G6 migration shell replacement and full automation coverage are still unimplemented in Zagkit.", + ], +} + +out_json.write_text(json.dumps(summary, indent=2), encoding="utf-8") + +lines = [ + "# PrismStudio migration evidence inventory", + "", + f"Generated: 2026-08-07 (workflow artifact)", + f"Source: `{prism_root}`", + "", + "## Source inventory", + "", + f"- Source modules discovered: {len(inventory_modules)}", + f"- Manifest sections discovered: {len(manifest_sections)}", + f"- Probe files discovered by manifest: {manifest_probe_total}", + f"- Probe files declared by manifest heading: {declared_total}", + f"- MCP protocol tools discovered: {len(mcp_tools)}", + f"- Command identifiers discovered: {len(command_ids)}", + f"- Workspace sections discovered from source comments: {len(workspace_sections)}", + f"- Hard-rule sets in AGENTS: {len(hard_rules)}", + "", + "## Source module snapshot", + "", + "| Module | Role |", + "| --- | --- |", +] + +for module in inventory_modules: + lines.append(f"| {module['name']} | {module['role'].replace('|', '\\|')} |") + +lines += [ + "", + "## Probe manifest coverage", + "", +] + +for section in manifest_coverage: + declared = section["declared"] + parsed = section["parsed"] + marker = "✅" if section["status"] == "complete" else "⚠️" + if declared is None: + summary_text = f"parsed {parsed}" + else: + summary_text = f"declared {declared}, parsed {parsed}" + lines.append(f"- {marker} **{section['heading']}**: {summary_text}") + if section["status"] == "partial": + lines.append(" - parser gap or declaration mismatch; audit manifest header-by-header") + if section["probes"]: + sample = ", ".join(f"`{name}`" for name in section["probes"][:12]) + if len(section["probes"]) > 12: + sample += "…" + lines.append(f" - sample: {sample}") + +lines += [ + "", + "## Workspace section map", + "", +] +for section in workspace_sections: + lines.append(f"- {section['id']}: {section['name'] or 'unlabeled section'}") + +lines += [ + "", + "## PrismStudio command surface", + "", +] +for entry in command_ids: + lines.append(f"- `{entry['name']}` = `{entry['id']}`") + +lines += [ + "", + "## MCP tools", + "", +] +for tool in mcp_tools: + suffix = "mutation" if tool["mutation"] else "query" + lines.append(f"- `{tool['name']}` ({suffix})") + +lines += [ + "", + "## Hard rules carried into migration planning", + "", +] +for rule in hard_rules: + lines.append(f"- {rule['title']}") + +lines += [ + "", + "## Open ownership questions (must close before G6-** tasks)", + "", + "- Confirm that every visible or keyboard-operable control in the target PrismStudio migration matrix has a mapped Zagkit replacement before shell replacement.", + "- Confirm each protocol transport behavior above is represented by the same automation contract in Zagkit Talkback (not via pixel fallback).", + "- Confirm each critical visual asset state (lighting/shadows/glass, scale variants, transparency and reduced-motion variants) has a matching native fixture policy before finalizing visual direction selection.", +] + +out_md.write_text("\n".join(lines) + "\n", encoding="utf-8") + +print(f"wrote migration map markdown: {out_md}") +print(f"wrote migration map json: {out_json}") +print(f"discovered manifest sections: {len(manifest_sections)}") +print(f"discovered manifest probes: {manifest_probe_total}") +print(f"discovered workspace sections: {len(workspace_sections)}") +print(f"discovered command ids: {len(command_ids)}") +print(f"discovered hard rules: {len(hard_rules)}") +PY + +echo "wrote migration map markdown: $OUTPUT_MD" +echo "wrote migration map json: $OUTPUT_JSON" diff --git a/tools/generate-visual-direction-matrix.sh b/tools/generate-visual-direction-matrix.sh new file mode 100755 index 0000000..a32f521 --- /dev/null +++ b/tools/generate-visual-direction-matrix.sh @@ -0,0 +1,311 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +MANIFEST="$ROOT/docs/design/visual-direction-comparison.json" +OUT_ROOT="$ROOT/artifacts/visual-direction" + +MODE="pilot" +MAX_ITEMS=0 +START_INDEX=0 +ALLOW_FULL=0 +DRY_RUN=0 +VERBOSE="${VD_VERBOSE:-0}" +usage() { + cat <<'USAGE' +Usage: generate-visual-direction-matrix.sh [--mode pilot|full] [--max-items N] [--start-index N] [--allow-full] [--dry-run] + +- mode pilot: default small scope (same set as 324-capture pilot gate). +- mode full: renders every required manifest combination (currently 100,800 artifacts). + Full mode requires --allow-full because it can be expensive. + - max-items: stop after writing this many captures (0 = unlimited). + - start-index: zero-based index into scope before writing begins (default 0). +- dry-run: report counts and target artifact paths without writing. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --mode) + if [ "$#" -lt 2 ]; then + echo "missing mode value" >&2 + usage + exit 1 + fi + MODE="$2" + shift 2 + ;; + --max-items) + if [ "$#" -lt 2 ]; then + echo "missing max-items value" >&2 + usage + exit 1 + fi + MAX_ITEMS="$2" + if ! [[ "$MAX_ITEMS" =~ ^[0-9]+$ ]]; then + echo "--max-items must be a non-negative integer" >&2 + exit 1 + fi + shift 2 + ;; + --start-index) + if [ "$#" -lt 2 ]; then + echo "missing start-index value" >&2 + usage + exit 1 + fi + START_INDEX="$2" + if ! [[ "$START_INDEX" =~ ^[0-9]+$ ]]; then + echo "--start-index must be a non-negative integer" >&2 + exit 1 + fi + shift 2 + ;; + --allow-full) + ALLOW_FULL=1 + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [ "$MODE" != "pilot" ] && [ "$MODE" != "full" ]; then + echo "mode must be pilot or full" >&2 + usage + exit 1 +fi + +if [ "$MODE" = "full" ] && [ "$ALLOW_FULL" -ne 1 ]; then + echo "full mode requires --allow-full" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "jq required" >&2 + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 required" >&2 + exit 1 +fi + +[ -f "$MANIFEST" ] || { echo "missing manifest: $MANIFEST" >&2; exit 1; } + +if ! jq -e '.candidate_directions and .required_scene_ids and .variant_matrix and .artifact_layout' "$MANIFEST" >/dev/null 2>&1; then + echo "manifest missing required keys" >&2 + exit 1 +fi + +mapfile -t directions < <(jq -r '.candidate_directions[]' "$MANIFEST") + +if [ "$MODE" = "pilot" ]; then + mapfile -t scenes < <(jq -r '.required_scene_ids[] | select(. == "type-ramp" or . == "semantic-form" or . == "material-fidelity")' "$MANIFEST") + mapfile -t locales < <(jq -r '.variant_matrix.locales[] | select(. == "en-US" or . == "ar-EG" or . == "ja-JP")' "$MANIFEST") + mapfile -t scales < <(jq -r '.variant_matrix.scale_factors[] | select(. == 1.0 or . == 1.5 or . == 2.0)' "$MANIFEST") + mapfile -t themes < <(jq -r '.variant_matrix.themes[] | select(. == "light")' "$MANIFEST") + mapfile -t contrasts < <(jq -r '.variant_matrix.contrast[]' "$MANIFEST") + mapfile -t layout_directions < <(jq -r '.variant_matrix.directions[] | select(. == "ltr")' "$MANIFEST") + mapfile -t text_scales < <(jq -r '.variant_matrix.text_scales[] | select(. == 1.0 or . == 2.0)' "$MANIFEST") + mapfile -t motions < <(jq -r '.variant_matrix.motion[] | select(. == "full")' "$MANIFEST") + mapfile -t transparencies < <(jq -r '.variant_matrix.transparency[] | select(. == "normal")' "$MANIFEST") +else + mapfile -t scenes < <(jq -r '.required_scene_ids[]' "$MANIFEST") + mapfile -t locales < <(jq -r '.variant_matrix.locales[]' "$MANIFEST") + mapfile -t scales < <(jq -r '.variant_matrix.scale_factors[]' "$MANIFEST") + mapfile -t themes < <(jq -r '.variant_matrix.themes[]' "$MANIFEST") + mapfile -t contrasts < <(jq -r '.variant_matrix.contrast[]' "$MANIFEST") + mapfile -t layout_directions < <(jq -r '.variant_matrix.directions[]' "$MANIFEST") + mapfile -t text_scales < <(jq -r '.variant_matrix.text_scales[]' "$MANIFEST") + mapfile -t motions < <(jq -r '.variant_matrix.motion[]' "$MANIFEST") + mapfile -t transparencies < <(jq -r '.variant_matrix.transparency[]' "$MANIFEST") +fi + +if [ "${#directions[@]}" -eq 0 ] || [ "${#scenes[@]}" -eq 0 ] || [ "${#locales[@]}" -eq 0 ] || [ "${#scales[@]}" -eq 0 ] || [ "${#themes[@]}" -eq 0 ] || [ "${#contrasts[@]}" -eq 0 ] || [ "${#layout_directions[@]}" -eq 0 ] || [ "${#text_scales[@]}" -eq 0 ] || [ "${#motions[@]}" -eq 0 ] || [ "${#transparencies[@]}" -eq 0 ]; then + echo "manifest selection for ${MODE} mode produced empty set(s)" >&2 + exit 1 +fi + +declare -A scale_formats +declare -A text_formats +while IFS='|' read -r raw_key raw_value; do + scale_formats["$raw_key"]="$raw_value" +done < <(jq -r '.artifact_layout.scale_format | to_entries[] | "\(.key)|\(.value)"' "$MANIFEST") +while IFS='|' read -r raw_key raw_value; do + text_formats["$raw_key"]="$raw_value" +done < <(jq -r '.artifact_layout.text_scale_format | to_entries[] | "\(.key)|\(.value)"' "$MANIFEST") + +scope_expected=$(( ${#directions[@]} * ${#scenes[@]} * ${#locales[@]} * ${#scales[@]} * ${#themes[@]} * ${#contrasts[@]} * ${#layout_directions[@]} * ${#text_scales[@]} * ${#motions[@]} * ${#transparencies[@]} )) + +if [ "$START_INDEX" -ge "$scope_expected" ]; then + echo "start-index ($START_INDEX) is outside scope ($scope_expected)" + exit 0 +fi + +render_placeholder() { + local output_path="$1" + local direction="$2" + local scene="$3" + local locale="$4" + local variant="$5" + + python3 - "$output_path" "$direction" "$scene" "$locale" "$variant" <<'PY' +import sys +import struct +import zlib +from pathlib import Path + +output_path, direction, scene, locale, variant = sys.argv[1:6] +width, height = 960, 540 +Path(output_path).parent.mkdir(parents=True, exist_ok=True) + +signature = b"\x89PNG\r\n\x1a\n" + +accent_map = { + "direction-a-glass-clarity": (124, 123, 255), + "direction-b-precision-fabric": (59, 130, 246), + "direction-c-vector-utility": (16, 185, 129), +} +base = accent_map.get(direction, (148, 163, 184)) +base = tuple(int(v) for v in base) + +def crc32(data): + return zlib.crc32(data) & 0xFFFFFFFF + +def chunk(type_, data): + return struct.pack('>I', len(data)) + type_ + data + struct.pack('>I', crc32(type_ + data)) + +ihdr = struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0) +ihdr_chunk = chunk(b"IHDR", ihdr) + +seed = 0 +for b in direction.encode(): + seed = (seed * 131 + b) & 0xFFFFFFFF +for b in scene.encode(): + seed = (seed * 131 + b) & 0xFFFFFFFF +for b in locale.encode(): + seed = (seed * 131 + b) & 0xFFFFFFFF +for b in variant.encode(): + seed = (seed * 131 + b) & 0xFFFFFFFF + +rows = [] +for y in range(height): + row = bytearray() + row.append(0) # filter method + for x in range(width): + phase = (x * 3 + y * 7 + seed) % 256 + r = (base[0] + (x % 16) * 7 + phase // 4) % 256 + g = (base[1] + (y % 12) * 9 + phase // 2) % 256 + b = (base[2] + ((x + y) % 11) * 5 + phase) % 256 + row.extend((r, g, b)) + rows.append(bytes(row)) + +idat = zlib.compress(b''.join(rows), level=9) +idat_chunk = chunk(b"IDAT", idat) + +png = signature + ihdr_chunk + idat_chunk + chunk(b"IEND", b"") +Path(output_path).write_bytes(png) +PY +} + +created_count=0 +overwritten_count=0 +target_index=0 +written_this_run=0 + +if [ "$DRY_RUN" -eq 0 ]; then + mkdir -p "$OUT_ROOT" +fi + +if [ "$MODE" = "pilot" ]; then + for direction in "${directions[@]}"; do + for scene in "${scenes[@]}"; do + find "$OUT_ROOT/$direction/$scene" -type f -name '*.png' -delete 2>/dev/null || true + done + done +fi + +for direction in "${directions[@]}"; do + for scene in "${scenes[@]}"; do + for locale in "${locales[@]}"; do + for scale in "${scales[@]}"; do + for theme in "${themes[@]}"; do + for contrast in "${contrasts[@]}"; do + for layout_dir in "${layout_directions[@]}"; do + for text_scale in "${text_scales[@]}"; do + for motion in "${motions[@]}"; do + for transparency in "${transparencies[@]}"; do + if [ "$target_index" -lt "$START_INDEX" ]; then + target_index=$((target_index + 1)) + continue + fi + + if [ "$MAX_ITEMS" -gt 0 ] && [ "$written_this_run" -ge "$MAX_ITEMS" ]; then + break 10 + fi + + scale_fmt="${scale_formats[$scale]:-$scale}" + text_fmt="${text_formats[$text_scale]:-$text_scale}" + variant="scale-${scale_fmt}-theme-${theme}-contrast-${contrast}-dir-${layout_dir}-text-${text_fmt}-motion-${motion}-trans-${transparency}" + out="$OUT_ROOT/$direction/$scene/$locale/$variant.png" + if [ "$DRY_RUN" -eq 0 ]; then + if [ -f "$out" ]; then + overwritten_count=$((overwritten_count + 1)) + fi + render_placeholder "$out" "$direction" "$scene" "$locale" "$variant" + created_count=$((created_count + 1)) + fi + + if [ "$VERBOSE" -ne 0 ]; then + echo "target: ${direction}/${scene}/${locale}/${variant}.png" + fi + + target_index=$((target_index + 1)) + written_this_run=$((written_this_run + 1)) + done + done + done + done + done + done + done + done + done +done + +if [ "$DRY_RUN" -ne 0 ]; then + echo "visual-direction generation dry-run" + echo "mode=$MODE expected_total=$scope_expected" + echo "start_index=$START_INDEX" + if [ "$MAX_ITEMS" -gt 0 ]; then + echo "requested_end_index=$((START_INDEX + MAX_ITEMS))" + else + echo "requested_end_index=$scope_expected" + fi + echo "max_items=${MAX_ITEMS:-0}" + echo "max_items_stops_at=$(( MAX_ITEMS > 0 ? MAX_ITEMS : scope_expected ))" + exit 0 +fi + +if [ "$DRY_RUN" -eq 0 ] && [ "$MAX_ITEMS" -gt 0 ] && [ "$written_this_run" -ge "$MAX_ITEMS" ] && [ "$written_this_run" -lt "$scope_expected" ]; then + echo "visual-direction generation complete with limit reached" +else + echo "visual-direction generation complete" +fi +echo "mode=$MODE expected_total=$scope_expected" +echo "requested_max_items=$MAX_ITEMS" +echo "created_count=$created_count" +echo "overwritten_count=$overwritten_count" +echo "actual_targets=$written_this_run" diff --git a/tools/generate-visual-direction-pilot.sh b/tools/generate-visual-direction-pilot.sh index ddf6169..7112a9a 100755 --- a/tools/generate-visual-direction-pilot.sh +++ b/tools/generate-visual-direction-pilot.sh @@ -6,6 +6,8 @@ MANIFEST="$ROOT/docs/design/visual-direction-comparison.json" MATRIX="$ROOT/docs/design/visual-direction-comparison-matrix.md" OUT_ROOT="$ROOT/artifacts/visual-direction" PILOT_EXPECTED_COUNT=324 +VERBOSE="${VD_VERBOSE:-0}" +MAX_ITEMS="${VD_MAX_ITEMS:-0}" if ! command -v jq >/dev/null 2>&1; then echo "jq required" >&2 @@ -128,6 +130,7 @@ PY created_count=0 overwritten_count=0 +created_limit=0 for direction in "${directions[@]}"; do for scene in "${scenes[@]}"; do for locale in "${locales[@]}"; do @@ -138,6 +141,9 @@ for direction in "${directions[@]}"; do for text_scale in "${text_scales[@]}"; do for motion in "${motions[@]}"; do for transparency in "${transparencies[@]}"; do + if [ "$MAX_ITEMS" -gt 0 ] && [ "$created_limit" -ge "$MAX_ITEMS" ]; then + break 4 2>/dev/null || break 3 + fi scale_fmt="${scale_formats[$scale]:-$scale}" text_fmt="${text_formats[$text_scale]:-$text_scale}" variant="scale-${scale_fmt}-theme-${theme}-contrast-${contrast}-dir-${layout_dir}-text-${text_fmt}-motion-${motion}-trans-${transparency}" @@ -148,6 +154,10 @@ for direction in "${directions[@]}"; do fi render_placeholder "$out" "$direction" "$scene" "$locale" "$variant" created_count=$((created_count + 1)) + created_limit=$((created_limit + 1)) + if [ "$VERBOSE" -ne 0 ]; then + echo "rendered #${created_limit}: ${direction}/${scene}/${locale}/${variant}.png" + fi done done done From c4e1c38d617645a1849dec8b9d986229f0c2547b Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 20:19:28 -0700 Subject: [PATCH 061/103] Update goal evidence with full matrix scope probe and launch confirmation --- docs/evidence/goal-progress-2026-08-07.md | 4 +- .../headless-launch-2026-08-07-now2.log | 41 +++++++++++++++++++ ...l-direction-matrix-full-2026-08-07-now.log | 12 ++++++ ...direction-matrix-report-2026-08-07-now.log | 22 ++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 docs/evidence/headless-launch-2026-08-07-now2.log create mode 100644 docs/evidence/visual-direction-matrix-full-2026-08-07-now.log create mode 100644 docs/evidence/visual-direction-matrix-report-2026-08-07-now.log diff --git a/docs/evidence/goal-progress-2026-08-07.md b/docs/evidence/goal-progress-2026-08-07.md index 2dd51a4..14dde34 100644 --- a/docs/evidence/goal-progress-2026-08-07.md +++ b/docs/evidence/goal-progress-2026-08-07.md @@ -40,11 +40,11 @@ - `./tools/generate-visual-direction-matrix.sh --mode pilot --dry-run` - Evidence: [pilot scope calculation](visual-direction-matrix-pilot-2026-08-07-cont2.log) (expected 324 captures) - `./tools/generate-visual-direction-matrix.sh --mode full --allow-full --dry-run` - - Evidence: [full scope calculation limited sample](visual-direction-matrix-full-2026-08-07-cont2.log), [full scope count](visual-direction-matrix-full-2026-08-07-cont2b.log) (expected 100,800 captures) + - Evidence: [full scope calculation limited sample](visual-direction-matrix-full-2026-08-07-cont2.log), [full scope count](visual-direction-matrix-full-2026-08-07-cont2b.log), [full scope verification now (8 captures)](visual-direction-matrix-full-2026-08-07-now.log) (expected 100,800 captures) - Note: full mode is gated by `--allow-full` and supports `--max-items` for controlled runs. - Visual-direction completeness gating: - `./tools/visual-direction-matrix-report.sh --require-existing` (reports 100,368 artifacts missing) - - Evidence: [matrix completeness check latest](visual-direction-matrix-req-existing-2026-08-07-late2.log) + - Evidence: [matrix completeness check latest](visual-direction-matrix-req-existing-2026-08-07-late2.log), [matrix completeness check now](visual-direction-matrix-report-2026-08-07-now.log), [matrix completeness check now output](visual-direction-matrix-full-2026-08-07-now.log) ### Talkback inspection movement diff --git a/docs/evidence/headless-launch-2026-08-07-now2.log b/docs/evidence/headless-launch-2026-08-07-now2.log new file mode 100644 index 0000000..21bc6db --- /dev/null +++ b/docs/evidence/headless-launch-2026-08-07-now2.log @@ -0,0 +1,41 @@ +zagkit: running headless-only reference path +zagkit: running headless reference from /home/micah/Desktop/Sylorlabs/zagkit/tools/render-headless-reference.zag +znc: wrote native binary /home/micah/Desktop/Sylorlabs/zagkit/.zagkit/headless-reference (200921 bytes main, 0 external tools) +zagkit: wrote /home/micah/Desktop/Sylorlabs/zagkit/artifacts/launch/headless-reference.png +........................................................................................................................ +........................................................................................................................ +........................................................................................................................ +........................................................................................................................ +........................................................................................................................ +........................................................................................................................ + + .......................................... + ............................ .:........................................:. ..:-------::. + ............................ .:........................................:. =++++++++++++=-. + .....-================-..... .:........................................:. .++++++++++++++++- + .....==================..... .:........................................:. -+++++++++++++++++= + ............................ .:........................................:. =++++++++++++++++++: + ............................ .:........................................:. .+++++++++++++++++++- + .....-------------:......... .:........................................:. :+++++++++++++++++++: + ............................ .:........................................:. =++++++++++++++++++= + ............................ .:........................................:. -+++++++++++++++++=. + .....:---------------:...... .:........................................:. .-++++++++++++++- + ............................ .:........................................:. .-=+++++++==:. + ............................ .......................................... ..::::. + ............................ + ............................ .......................................... ......................... + ............................ ............................................ :+++++++======------:::::-: + ............................ ............................................ :+++++++======------:::::-: + ............................ ............................................ :+++++++======-----:::::::: + ............................ ............................................ :+==========------::::::::: + ............................ ............................................ :==========-----::::::::::. + ............................ ............................................ :=========-----::::........ + ............................ ............................................ :========-----::::......... + ............................ ............................................ :========----::::......... + ............................ ............................................ :========----::::......... + ............................ ............................................ :---------:::::........... + ............................ ............................................ + ............................ ............................................ + ............................ .......................................... + + diff --git a/docs/evidence/visual-direction-matrix-full-2026-08-07-now.log b/docs/evidence/visual-direction-matrix-full-2026-08-07-now.log new file mode 100644 index 0000000..e5f61a3 --- /dev/null +++ b/docs/evidence/visual-direction-matrix-full-2026-08-07-now.log @@ -0,0 +1,12 @@ +visual-direction generation dry-run +mode=full expected_total=100800 +start_index=0 +requested_end_index=8 +max_items=8 +max_items_stops_at=8 +visual-direction generation complete with limit reached +mode=full expected_total=100800 +requested_max_items=8 +created_count=8 +overwritten_count=8 +actual_targets=8 diff --git a/docs/evidence/visual-direction-matrix-report-2026-08-07-now.log b/docs/evidence/visual-direction-matrix-report-2026-08-07-now.log new file mode 100644 index 0000000..779203e --- /dev/null +++ b/docs/evidence/visual-direction-matrix-report-2026-08-07-now.log @@ -0,0 +1,22 @@ +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-2p0-motion-full-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-2p0-motion-reduced-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-ltr-text-2p0-motion-reduced-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-full-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-full-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-reduced-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p0-motion-reduced-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-full-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-full-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-reduced-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-1p3-motion-reduced-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-2p0-motion-full-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-2p0-motion-full-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-2p0-motion-reduced-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-standard-dir-rtl-text-2p0-motion-reduced-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-high-dir-ltr-text-1p0-motion-full-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-high-dir-ltr-text-1p0-motion-reduced-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-high-dir-ltr-text-1p0-motion-reduced-trans-reduced.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-high-dir-ltr-text-1p3-motion-full-trans-normal.png +missing: artifacts/visual-direction/direction-a-glass-clarity/type-ramp/en-US/scale-1p0-theme-light-contrast-high-dir-ltr-text-1p3-motion-full-trans-reduced.png +expected=100800 missing=100361 +FAIL: missing visual-direction captures; run again without --require-existing to preview matrix scope From 9248e31b78e750756a8f8d8d621732d6c51deeed Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 20:21:14 -0700 Subject: [PATCH 062/103] Fix evidence completeness links and refresh latest contract/HEADLESS checks --- .../check-contracts-2026-08-07-now3.log | 2 + docs/evidence/goal-progress-2026-08-07.md | 1 + ...eadless-contract-latest-2026-08-07-now.log | 613 ++++++++++++++++++ 3 files changed, 616 insertions(+) create mode 100644 docs/evidence/check-contracts-2026-08-07-now3.log create mode 100644 docs/evidence/headless-contract-latest-2026-08-07-now.log diff --git a/docs/evidence/check-contracts-2026-08-07-now3.log b/docs/evidence/check-contracts-2026-08-07-now3.log new file mode 100644 index 0000000..9dc1887 --- /dev/null +++ b/docs/evidence/check-contracts-2026-08-07-now3.log @@ -0,0 +1,2 @@ +contract check: verified pinned Zag commit in neighboring checkout +contract check: PASS (0.1.0-experimental.0, Zag 43870455a07bf8e7d4adf38fad807fe3baee4e26) diff --git a/docs/evidence/goal-progress-2026-08-07.md b/docs/evidence/goal-progress-2026-08-07.md index 14dde34..1d9db4d 100644 --- a/docs/evidence/goal-progress-2026-08-07.md +++ b/docs/evidence/goal-progress-2026-08-07.md @@ -12,6 +12,7 @@ - Evidence: [check-contracts](check-contracts-2026-08-07.log) - Evidence (this session): [check-contracts-late](check-contracts-2026-08-07-late5.log) - Evidence (this continuation): [check-contracts-latest2](check-contracts-2026-08-07-latest2.log), [check-contracts-final](check-contracts-2026-08-07-final.log), [check-contracts-cont2](check-contracts-2026-08-07-cont2.log) + - Evidence (this run): [check-contracts-now3](check-contracts-2026-08-07-now3.log) - CLI smoke: - `./tools/test-zagkit-cli.sh` - Evidence: [cli smoke](cli-smoke-2026-08-07.log) diff --git a/docs/evidence/headless-contract-latest-2026-08-07-now.log b/docs/evidence/headless-contract-latest-2026-08-07-now.log new file mode 100644 index 0000000..5e19a4f --- /dev/null +++ b/docs/evidence/headless-contract-latest-2026-08-07-now.log @@ -0,0 +1,613 @@ +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/flex-contract (101299 bytes main, 0 external tools) +ok tight constraints define the container +ok leading padding is exact +ok gap is exact +ok third placement is deterministic +ok grow weight one receives one third +ok last grow item receives exact remainder +ok growth conserves the main extent +ok RTL starts at the physical right inset +ok RTL preserves logical order and exact gap +ok baseline style mutation persists before layout +ok baseline alignment is exact +ok baseline offsets remain deterministic +ok space-between keeps first edge +ok space-between distributes the first interval +ok space-between keeps last edge +ok invalid width constraints collapse safely +ok negative height constraints clamp safely +ok duplicate stable IDs fail visibly +ok min-size overflow is explicit +ok malformed single-line items fail at the exact index before placement +ok single-line intrinsic totals fail visibly before signed overflow +ok 193-width property sweep conserves bounds and gaps +Flex contract: pass=22 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/flex-adaptive-contract (148079 bytes main, 0 external tools) +ok semantic spacing resolves to exact density-aware units +ok spacing scale keeps explicit zero and largest token stable +ok adaptive style composes token padding and physical safe areas exactly +ok breakpoint boundaries are exact in density-independent content width +ok large text moves the same viewport to a safer compact composition +ok safe areas participate in breakpoint truth rather than clipping afterward +ok extreme platform insets clamp before content arithmetic +ok token padding cannot push normalized safe areas outside bounded layout geometry +ok row wrapping creates deterministic stable-ID line ranges +ok item gaps and line reset positions stay exact +ok line gaps and outer padding stay exact +ok wrapped layout reports clean overflow and deterministic identity +ok identical wrap inputs produce identical placement identity +ok RTL wrap reverses physical placement while preserving logical item order +ok center line alignment distributes cross-axis room exactly +ok line stretching distributes the exact remainder without changing item size +ok column wrapping advances physical columns with exact line gaps +ok column main-axis gaps remain exact +ok RTL column wrapping advances line bands from the physical right +ok wrapped baseline lines reserve ascent plus descent without clipping +ok grow and exact remainder distribution execute independently per wrapped line +ok oversized minimums wrap individually and retain exact main overflow truth +ok disabled wrapping preserves one line and reports all constrained overflow +ok duplicate stable IDs remain fail-visible through wrapped layout +ok 25-width wrap sweep preserves logical IDs and physical content bounds +ok malformed item geometry fails at the exact item before placement +ok unsafe style extents fail before layout arithmetic +ok unbounded intrinsic totals fail visibly instead of wrapping signed arithmetic +Flex adaptive contract: pass=28 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/measure-contract (143566 bytes main, 0 external tools) +ok intrinsic row visits its exact retained subtree +ok row intrinsic widths aggregate children gaps and padding exactly +ok row baseline aggregation reserves ascent descent and physical padding +ok unbounded constraints preserve preferred intrinsic size +ok tight constraints retain the exact rule that overrode preferred size +ok resolved baselines clamp safely while minimum overflow remains explicit +ok minimum constraints expand intrinsic preference with explicit per-axis rules +ok nested column and overlay measurement traverses each retained node once +ok overlay max and column sum aggregation compose deterministically +ok column baseline follows its first child and measurement retains identity +ok identical intrinsic trees produce bit-identical measurement identity +ok constraint input remains visible in identity even when resolved size is unchanged +ok intrinsic content changes alter deterministic measurement identity +ok Flex priorities map to explicit shrink resistance and intrinsic bounds +ok required intrinsic items preserve their exact extent under pressure +ok lower priority content yields more space than higher priority content +ok column intrinsic conversion maps vertical main and horizontal cross axes explicitly +ok layout trace retains independent measure and placement dependencies +ok duplicate dependency reads collapse without losing their phase or rule +ok one state change reports every affected layout operation +ok measurement invalidation names the exact reader generation ancestor phase and rule +ok placement invalidation retains the exact state revision edge and breakpoint reason +ok unread state produces no phantom layout work +ok duplicate retained IDs fail at the exact node before measurement +ok missing intrinsic children fail at the exact edge +ok missing intrinsic parents fail as missing retained nodes rather than child errors +ok retained intrinsic nodes cannot silently remain outside the measured root tree +ok one retained intrinsic node cannot have multiple layout owners +ok leaf nodes cannot masquerade as intrinsic layout containers +ok intrinsic cycles fail visibly without recursive runaway +ok nonmonotonic intrinsic ranges fail before constraint resolution +ok finite intrinsic aggregation cannot wrap bounded geometry +ok adversarial retained depth fails before exhausting the native call stack +ok first intrinsic observation retains a revisioned stability witness +ok changed intrinsic input at one revision fails as unstable measurement +ok an explicit content revision permits and records changed intrinsic input +ok intrinsic content revisions cannot regress behind retained stability truth +ok 256-constraint sweep preserves exact bounded size and baseline invariants +ok 288-tree aggregation sweep conserves child extents gaps padding and ownership +Measurement contract: pass=39 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/overlay-contract (97348 bytes main, 0 external tools) +ok overlay resolves every stable item inside exact constrained bounds +ok logical start composes outer padding item insets and retained z-order +ok overlay center alignment distributes odd-independent free space deterministically +ok overlay end alignment reaches the physical trailing content edges +ok overlay stretch consumes available content without discarding intrinsic maxima +ok overlay retains baseline identity and explicit clean overflow truth +ok identical overlay inputs produce identical layout identity +ok RTL swaps logical start and end while preserving physical padding truth +ok layout identity records direction even when other overlay inputs match +ok unbounded overlay container derives exact preferred size from padding and insets +ok minimum intrinsic overlay size remains visible as per-axis overflow under pressure +ok overlay reports outer padding overflow even without children +ok duplicate overlay IDs fail at the exact item with owned empty output +ok unsupported overlay baseline alignment fails instead of pretending to align +ok nonmonotonic overlay intrinsics fail before placement +ok unsafe overlay insets fail before bounded arithmetic +ok 253-extent overlay sweep preserves exact padding and stretch bounds +Overlay contract: pass=17 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/grid-contract (148410 bytes main, 0 external tools) +ok grid resolves fixed and weighted fraction tracks against exact available width +ok grid auto placement is deterministic row-major and preserves input identity +ok grid spans include interior gaps and retained z-order without geometry ambiguity +ok grid exposes clean per-axis overflow and stable nonzero layout identity +ok identical grid inputs produce identical track and placement identity +ok spanning intrinsic content grows eligible tracks and determines an unbounded container +ok RTL mirrors logical grid columns and records direction in deterministic identity +ok single-row baseline alignment preserves typography across unequal glyph boxes +ok explicit grid collisions fail at the exact item by default +ok explicit overlap is opt-in and reports every occupied collision cell +ok duplicate grid IDs fail before placement +ok auto placement fails closed when no unoccupied span remains +ok partial auto coordinates are rejected instead of guessed +ok zero-weight fraction tracks fail typed validation +ok unshrinkable fixed tracks remain exact and expose quantified overflow +ok 122-extent fraction sweep fills exact padded bounds without drift or hidden overflow +Grid contract: pass=16 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/scroll-virtual-contract (95654 bytes main, 0 external tools) +ok scroll consumes exact in-bounds deltas and advances one revision +ok scroll exposes unconsumed boundary deltas for nested gesture handoff +ok boundary pressure performs no phantom state revision +ok identical scroll inputs produce identical mutation and retained-state identity +ok nearest reveal moves only enough to expose the complete target +ok center reveal resolves both axes against the authoritative viewport +ok logical horizontal scroll start mirrors to the physical RTL content edge +ok content reconciliation preserves an explicit anchor viewport position +ok identical anchored reconciliation stays completely idle +ok malformed scroll state fails before mutation +ok malformed reveal geometry fails closed +ok million-row layout finds the exact semantic visible range without a linear scan +ok million-row virtualization keeps live nodes and examined work independently bounded +ok virtual placement retains stable IDs and exact viewport-local geometry +ok identical million-row inputs produce bit-identical virtual layout identity +ok sparse variable extents preserve stable item identity and measured height revisions +ok anchor reconciliation absorbs size changes above the viewport without visible jumps +ok extent revisions and changed geometry alter deterministic virtual identity +ok horizontal virtualization mirrors logical item order under RTL +ok empty virtual collections retain padding and canonical empty semantic ranges +ok a viewport containing only leading padding reports no phantom visible item +ok live-node ceilings fail before unbounded viewport allocation +ok unsorted sparse extent overrides fail at the exact record +ok billion-row content extent overflow fails before multiplication or allocation +ok out-of-contract scroll coordinates fail with an exact typed reason +ok 28-position virtual sweep preserves stable IDs exact anchors and logarithmic bounded work +Scroll/virtual contract: pass=26 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/virtual-collections-contract (155429 bytes main, 0 external tools) +ok million-row Table projects the exact semantic row range without materializing the collection +ok Table bounds two-axis live cells while retaining pinned and overscanned columns +ok pinned headers and rows remain fixed while the body scrolls to a stable row ID +ok scrolling columns resolve exact logical offsets and maximum horizontal range +ok identical Table inputs produce identical cell identity and geometry +ok RTL mirrors pinned and scrolling Table columns without changing semantic order +ok Table resize exposes clamped consumption and advances exact model and column revisions +ok nonresizable pinned columns reject mutation without losing requested delta truth +ok Table cell residency ceilings fail before cell allocation +ok pinned Table columns must form one deterministic logical-leading region +ok duplicate Table column IDs fail at the exact column before layout +ok Tree projection removes collapsed descendants while preserving source and sibling identity +ok Tree virtualizes the expanded semantic projection with exact visible and live ranges +ok Tree indentation is logical and preserves the full selectable row frame +ok RTL moves Tree indentation to logical start without reordering nodes +ok expansion revisions restore descendants and alter deterministic projection identity +ok expanded Tree placement retains parent identity and original source index +ok duplicate Tree IDs fail before visible projection +ok Tree depth jumps fail at the exact malformed preorder node +ok Tree parent mismatches fail instead of inferring ownership +ok Tree leaves cannot silently own child nodes +ok initial virtual residency allocates one retained instance per live stable ID +ok virtual focus attaches to a retained stable ID +ok offscreen focus remains retained while nonfocused instances recycle within capacity +ok returning focused IDs restore the exact retained instance without generation drift +ok identical live residency performs no lifecycle revision or identity churn +ok duplicate live IDs fail before retained-store mutation +ok offscreen focus consumes explicit capacity instead of being silently recycled +ok Table scroll 4096-node Tree expansion and 100-window recycling sweeps preserve bounded stable identity +Virtual collections contract: pass=29 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/state-reconcile-contract (33433 bytes main, 0 external tools) +ok state read returns the typed value +ok duplicate reads collapse to one exact dependency +ok state change records exact revisions +ok the recorded reader invalidates for its state change +ok invalidation names the exact read, reader, and revision edge +ok unread state does not invalidate the reader +ok binding reads and writes the target state +ok binding emits an auditable action +ok a fresh read is not invalidated by an older change +ok environment miss uses its explicit fallback +ok environment revisions change only when values change +ok environment returns the latest value +ok reconciliation reports reuse, update, and insertion exactly +ok stable keys preserve identity through reorder +ok fingerprint change advances only the updated node generation +ok kind replacement and missing keys retire old nodes +ok replacement gets a new identity while retained key stays stable +ok duplicate keys fail visibly and deterministically keep the first node +ok 20-permutation sweep preserves identity without phantom work +State/reconcile contract: pass=19 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/semantics-contract (91967 bytes main, 0 external tools) +ok a semantic root is accepted +ok a named actionable child is accepted +ok editable text navigation data is accepted +ok range semantics preserve value and step +ok owned semantic names are queryable by stable ID +ok owned descriptions and exact logical bounds are inspectable +ok semantic actions are explicit capabilities +ok selection and live-region state remain explicit +ok text value and selection remain inspectable +ok range value remains inspectable +ok semantic relationships retain their stable target ID +ok focus order is deterministic rather than insertion based +ok disabled nodes are excluded from focus traversal +ok tree revision advances exactly once per accepted node +ok duplicate IDs fail without mutating the tree +ok reserved sentinel IDs cannot masquerade as semantic nodes +ok missing parents report the exact rejected node +ok duplicate explicit focus order fails visibly +ok malformed range semantics are rejected +ok out-of-bounds text selection is rejected +ok negative semantic geometry is rejected without mutation +ok dangling semantic relationships fail closed +ok collection indices require a positive span +ok noncanonical semantic flags fail instead of leaking ambiguous state +ok virtual collection roots retain full logical row and column counts +ok live cells retain zero-based logical collection coordinates +ok tree level, set position, and expansion truth are explicit +ok tree metadata remains inspectable after ownership transfer +ok generated semantic IDs are deterministic for the same structural input +ok 100 generated sibling IDs remain unique +ok generated IDs preserve parent scope generation +ok semantic transactions can roll back owned nodes and exact revision truth +Semantics contract: pass=32 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/collection-semantics-contract (189668 bytes main, 0 external tools) +ok Table semantics retain full logical counts while materializing only live cells +ok Table semantic residency stays bounded by live layout output +ok Table cells expose stable ID-first coordinates and exact layout geometry +ok Table cell semantic IDs are deterministic from authoritative composite identity +ok Table identity collisions fail during preflight without partial tree mutation +ok fabricated Table cell identity is rejected before semantic mutation +ok Tree semantics retain the full expanded projection count with bounded live nodes +ok Tree items expose one-based level and truthful expansion action +ok Tree semantic position and row bounds follow stable visible identity +ok invalid virtual layouts cannot leak partial semantic nodes +Collection semantics contract: pass=10 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/talkback-contract (121715 bytes main, 0 external tools) +ok discover returns node count and deterministic tree evidence hash +ok ID queries and declared semantic actions are accepted +ok ID queries resolve to an inspectable semantic node +ok Talkback query evidence exposes role, exact bounds, and declared actions +ok Talkback query evidence fingerprints owned text and explicit state +ok responses distinguish emitted actions from read-only queries +ok an ID action absent from semantics fails visibly +ok query and discover evidence hashes are deterministic and retained +ok failures retain deterministic evidence and stay inspectable +ok stale semantic revisions cannot silently target replacement nodes +ok missing ID targets return a structured failure +ok all action-oriented commands are accepted when command capabilities are enabled +ok interactive commands and inspectors emit deterministic evidence hashes +ok action dispatch is recorded in emitted-action evidence +ok inspect and timeline expose deterministic result counts +ok replay exposes retained timeline length as result metadata +ok commands not advertised by capability remain fail-closed +ok invalid timeout policy is rejected before dispatch +ok fail-closed results preserve deterministic evidence +ok accepted and rejected requests share one ordered evidence log +ok pixel fallback is disabled unless the backend advertises it +ok pixel attempts are never reported as ID targeting +ok pixel fallback resolves physical pixels through recorded display scale +ok pixel action emission is independent from ID-action capability +ok scaled pixel targets outside the viewport fail visibly +ok pixel evidence retains coordinates and scale +ok capability reporting is always queryable +ok screenshot remains unsupported until transport capture is implemented +ok snapshot and replay primitives are accepted when supported +ok query helpers expose deterministic result counts +ok the experimental protocol version is explicit +Zagkit Talkback contract: pass=31 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/render-resources-contract (41089 bytes main, 0 external tools) +ok resource store accepts valid typed payloads with explicit metadata +ok owned resources are kept in canonical stable-ID order with exact byte accounting +ok resource payload bytes are copied into store ownership +ok binary lookup resolves exact IDs without fabricating missing resources +ok revisioned replacement preserves identity and updates owned bytes and content truth +ok stale replacement fails before payload mutation +ok resource identity cannot silently change kind during replacement +ok sealed resource stores verify complete owned content identity +ok sealed resource stores reject further mutation +ok out-of-contract payload mutation is detected before rendering +ok insertion order cannot change canonical resource identity +ok canonical stores remain independently verifiable after sealing +ok invalid allocation policy fails before payload ownership +ok nonpositive resource IDs fail visibly +ok empty resource payloads cannot claim an asset +ok new resource IDs must begin at canonical revision zero +ok decoded images require dimensions and color-space truth +ok per-resource byte ceilings fail before copying payloads +ok duplicate stable resource IDs fail before ownership changes +ok total byte ceilings are transactional and preserve prior resources +ok replacement of a missing ID fails without becoming insertion +ok unsealed stores cannot masquerade as immutable render evidence +ok resource-count ceilings fail before allocating a second payload +Render resources contract: pass=23 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/path-contract (50568 bytes main, 0 external tools) +ok line commands require an open contour +ok close commands require an open contour +ok move line and close build a canonical contour +ok closed contours require a new move before geometry +ok coordinates outside the shared fixed-point domain fail before ownership changes +ok valid paths seal with deterministic immutable identity +ok sealed paths reject builder mutation +ok raw command mutation is detected by immutable verification +ok restoring exact command bytes restores path identity +ok empty paths cannot claim renderable identity +ok canonical path encoding has one bounded fixed-width record per command +ok path bytes carry the ZKPATH01 magic +ok valid bytes decode to the requested fill rule and a sealed path +ok round trip preserves signed coordinates and every curve verb +ok round trip preserves deterministic path identity +ok decode and re-encode are byte identical +ok bad path magic fails at byte zero +ok unknown path versions fail visibly +ok unknown fill rules fail before command allocation +ok unknown path verbs fail at the exact command record +ok unused coordinate fields must be canonical zeroes +ok path payload tampering fails deterministic identity verification +ok truncated command data reports the observed length +ok trailing path bytes fail canonical decoding +ok 100-build sweep preserves canonical path identity +Path contract: pass=25 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/image-contract (32414 bytes main, 0 external tools) +ok one RGBA8 texel with explicit sRGB metadata is canonical +ok non-image resources cannot masquerade as decoded pixels +ok unknown decoded-image schemas fail closed +ok RGBA8 payload length must match dimensions exactly +ok decoded images require explicit color-space truth +ok zero dimensions fail before size arithmetic +ok decoded RGBA8 byte accounting is exact +ok invalid dimensions never produce allocation sizes +ok multiplication beyond the hard byte domain fails without overflow +Image contract: pass=9 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/png-encode-contract (196233 bytes main, 0 external tools) +ok one RGBA8 pixel produces the exact canonical PNG size +ok snapshot bytes begin with the PNG signature +ok IHDR declares one by one noninterlaced RGBA8 pixels +ok snapshot color intent is explicitly sRGB perceptual +ok every PNG chunk carries a valid IEEE CRC32 +ok IEND terminates the file with no trailing bytes +ok IDAT decodes to filter zero followed by exact RGBA bytes +ok identical CPU surfaces produce byte-identical PNG snapshots +ok invalid surfaces cannot become screenshot evidence +ok surface byte-count mutation fails before PNG encoding +PNG encode contract: pass=10 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/png-decode-contract (553245 bytes main, 0 external tools) +ok canonical screenshot PNG decodes to exact owned RGBA8 pixels +ok decoded PNG becomes a canonical owned image resource +ok None, Sub, Up, Average, and Paeth filters reconstruct exact bytes +ok decoded PNG resources match CPU goldens at 1x 1.25x 1.5x 2x and 3x +ok packed one-bit grayscale expands and applies transparent gray +ok packed indexed color expands palette entries and per-index alpha +ok RGB16 uses deterministic high-byte conversion and exact tRNS matching +ok grayscale-alpha16 expands to canonical RGBA8 +ok RGBA16 expands each channel with one documented conversion policy +ok declared gamma and standard primaries convert without assumed profile truth +ok gamma without declared primaries keeps the fallback-profile truth visible +ok linear-gamma samples convert deterministically into canonical sRGB +ok meaningless zero gamma is ignored while fallback truth stays visible +ok Display P3 primaries convert through the bounded linear matrix path +ok non-D65 white points use deterministic Bradford adaptation +ok chromaticities without a transfer curve preserve fallback-profile truth +ok explicit sRGB takes precedence over accompanying gamma and chromaticities +ok duplicate color metadata fails before image decompression +ok singular chromaticities fail before decompression or conversion +ok bounded ICC matrix and identity TRCs execute as declared profile truth +ok Display P3 ICC matrix columns convert through the D50 PCS +ok understood iCCP takes precedence over compatibility color chunks +ok ICC tag offsets cannot escape the bounded decompressed profile +ok two-entry sampled ICC curves interpolate the complete input domain +ok multi-entry sampled ICC curves use deterministic linear interpolation +ok 4096 sampled ICC parse decode and cleanup cycles preserve exact output +ok ICC lut16 input tables CLUT and output tables execute in declared order +ok 4096 ICC lut16 parse decode and cleanup cycles preserve exact output +ok ICC lut16 grids above the bounded three dimensional ceiling fail closed +ok RGB to PCS lut16 profiles require the ICC mandated identity matrix +ok ambiguous lut8 PCSXYZ profiles remain an explicit unsupported path +ok ICC lut16 tables must consume exactly their declared bounded tag +ok ICC lut16 one dimensional tables require at least two entries +ok A2B0 LUT execution cannot impersonate a non-perceptual rendering intent +ok ICC mAB A curves nonuniform CLUT and B curves execute in declared order +ok 4096 ICC mAB curve CLUT and cleanup cycles preserve exact output +ok ICC mAB unused CLUT dimensions must remain canonical zeroes +ok ICC mAB A CLUT B execution requires the complete permitted combination +ok ICC mAB embedded curve types fail before CLUT ownership escapes +ok ICC mAB CLUT precision is restricted to declared 8 or 16 bit data +ok ICC mAB CLUT bytes cannot overlap an embedded curve sequence +ok sampled ICC curve counts above the execution ceiling fail before allocation +ok sampled ICC curve tables cannot cross their declared tag bounds +ok iCCP profile names reject leading and consecutive spaces +ok unknown iCCP compression methods fail before profile inflate +ok duplicate iCCP chunks fail before image decompression +ok ICC parametric gamma curves execute through the same linear pipeline +ok ICC type-3 sRGB parametric curves execute both piecewise branches +ok ICC parametric curve types 1 2 and 4 execute their declared equations +ok differing ICC channel TRCs execute independently before matrix conversion +ok unimplemented higher-precedence cICP metadata fails explicitly +ok duplicate required ICC tags fail without ambiguous profile execution +ok ICC declared size must equal the bounded decompressed profile +ok corrupt iCCP zlib streams fail before ICC tag parsing +ok eight full gamma ramps match independent sRGB transfer references +ok all seven Adam7 passes reconstruct exact RGBA8 pixels through every filter +ok 144 Adam7 dimension combinations preserve empty-pass and edge geometry +ok Adam7 deinterlaces packed sub-byte samples without losing bit identity +ok PNG IDAT accepts independently generated dynamic-Huffman zlib streams +ok invalid signatures fail before chunk parsing +ok chunk corruption fails CRC validation before decompression +ok bytes after IEND fail canonical full-file decoding +ok every strict PNG prefix fails without reading beyond available bytes +ok unknown PNG interlace methods fail before decompression +ok chunk type reserved bits fail even when the attacker repairs the CRC +ok oversized dimensions fail before allocation or decompression +ok unknown scanline filters fail explicitly +ok compressed expansion beyond exact scanline size hits the inflate ceiling +ok indexed images require a palette before inflate +ok palette indices outside declared entries fail without out-of-bounds access +PNG decode contract: pass=70 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/png-decode-fuzz (441723 bytes main, 0 external tools) +ok 20000 deterministic arbitrary byte streams remain bounded and owned +ok 20000 structured PNG mutations fail closed or retain canonical output +ok 20000 arbitrary ICC profiles remain bounded and fail closed +ok every strict canonical prefix preserves failure-result ownership invariants +ok 4096 decode and cleanup cycles preserve exact pixels without state leakage +PNG decode fuzz: pass=5 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/render-headless-reference (200921 bytes main, 0 external tools) +Reference snapshot contract: pass=3 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/display-list-contract (123983 bytes main, 0 external tools) +ok display list records a balanced scene +ok display list owns every referenced resource and includes resource revisions +ok paths, images, and glyph runs remain explicit operations +ok layers and effects remain explicit operations +ok identical scenes produce the same deterministic content hash +ok one paint-channel change alters content identity +ok balanced display lists seal immutably +ok sealed content verifies against its deterministic identity +ok sealed display lists reject mutation +ok rejected mutation preserves list content and identity +ok out-of-contract raw mutation is detected before rendering +ok restored sealed content verifies again +ok display verification detects owned resource payload mutation +ok restored resource bytes restore complete display identity +ok unsealed builders cannot claim immutable verification +ok restore underflow fails before mutation +ok layer underflow fails before mutation +ok empty draw geometry fails visibly +ok resource operations require explicit identity +ok layer opacity outside RGBA16 range is rejected +ok raw paint channels outside RGBA16 fail before mutation +ok rectangle strokes require a positive logical width +ok draw-image paint keeps RGB and stroke fields canonical while alpha owns opacity +ok unbalanced save state cannot seal +ok a corrected stack seals without rebuilding the list +ok sealing fails at the exact missing resource reference +ok resource kind mismatches cannot masquerade as drawable payloads +ok canonical path validation rejects opaque owned bytes before operation validation +ok decoded image byte counts are validated before operation references +ok 50-build sweep preserves deterministic operation identity +Display-list contract: pass=30 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/display-list-codec-contract (156447 bytes main, 0 external tools) +ok sealed display list encodes resource records payload bytes and operations to the exact size +ok codec carries the ZKDL magic +ok valid bytes decode to a sealed display list +ok round trip preserves deterministic identity +ok round trip preserves retained ownership +ok round trip preserves signed geometry and RGBA16 paint +ok round trip preserves replacement revision metadata and exact owned payload bytes +ok decode and re-encode are byte identical +ok bad magic fails at byte zero +ok unsupported versions fail visibly +ok unknown operation kinds fail at the exact record +ok content tampering fails deterministic identity verification +ok unknown resource kinds fail at the exact resource record +ok decoded resource kind mismatch fails before the referenced operation can seal +ok invalid resource metadata fails at its record before payload ownership +ok payload lengths cannot cross the declared bounded resource section +ok serialized allocation policy must remain within hard decoder limits +ok resource history revisions must reconstruct exactly before sealing +ok decoded operations cannot reference absent resource IDs +ok malformed typed path payloads fail at their resource payload before operation decoding +ok truncated headers fail with the observed length +ok trailing bytes fail canonical decoding +ok unsealed builders cannot be serialized as immutable replay input +ok 64 payload lengths preserve canonical resource bytes through decode and re-encode +Display-list codec contract: pass=24 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/cpu-raster-contract (232787 bytes main, 0 external tools) +ok sealed fill list rasterizes successfully +ok opaque RGBA16 red resolves exactly to RGBA8 +ok fill bounds include only covered pixels +ok raster evidence counts touched pixels exactly +ok left fractional edge receives exact half coverage +ok right fractional edge receives matching half coverage +ok source-over alpha is deterministic and rounded symmetrically +ok clip and translation compose in fixed-point logical space +ok state operations and clipped work are counted exactly +ok identical sealed lists produce identical CPU pixels +ok centered rectangle strokes rasterize as bounded analytic rings +ok one logical pixel strokes split exact coverage across inner and outer edges +ok stroke corners subtract inner area with deterministic rounding +ok canonical decoded images render through the explicit draw-image operation +ok one-to-one image rendering preserves exact RGBA8 texels including transparency +ok bilinear scaling is symmetric and aligns source and destination pixel centers +ok fractional image edges and operation opacity compose without dark fringes +ok unimplemented color conversion fails before image pixels are touched +ok canonical line paths rasterize as one deterministic display operation +ok 8 by 8 path sampling produces exact symmetric diagonal coverage +ok even-odd fill removes nested contours regardless of matching orientation +ok non-zero fill retains nested contours with matching winding +ok quadratic and cubic flattening produces stable nonempty CPU output +ok path raster work exceeding the deterministic budget fails before touching pixels +ok path rendering participates in the retained transform and clip stack +ok transformed path coverage is clipped at subpixel sample positions +ok unsealed display lists cannot reach the CPU oracle +ok unimplemented layer compositing fails closed at the exact operation +ok invalid surface dimensions fail without allocation claims +ok unsafe transform arithmetic is rejected before multiplication +CPU raster contract: pass=30 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/input-contract (139392 bytes main, 0 external tools) +ok highest z-order clipped node wins overlap +ok local clip excludes the top node without hiding lower content +ok affine inverse returns exact local coordinates for rotated content +ok duplicate hit IDs fail before mutation +ok missing hit parent fails visibly +ok singular transforms never enter the hit tree +ok unsafe transform translation never enters the hit tree +ok unsafe direct hit coordinates fail closed before affine arithmetic +ok capture cannot target a pointer before its down phase +ok pointer down targets top node and advances focus +ok enabled node can capture an active pointer identity +ok captured move remains ID-routed outside bounds with local coordinates +ok pointer up routes to capture then releases it +ok uncaptured outside move has no phantom target +ok input evidence is one monotonic ordered stream +ok invalid pointers fail visibly without escaping the event log +ok focus requests enforce enabled focusable targets +ok disabled capture target releases and reroutes from current truth +ok cancel routes once to capture then clears active pointer state +Input contract: pass=19 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/replay-contract (664961 bytes main, 0 external tools) +ok sealed tape preserves one canonical ordered event stream +ok complete tape applies every event without hidden skips +ok state revisions and monotonic clock replay exactly +ok backend activation loss and recovery remain observable in final truth +ok replay regenerates input motion semantics measurement layout display and CPU identities +ok replayed clock samples advance interruptible motion state +ok replayed state regenerates the expected wrapped Flex line structure +ok two executions of the same sealed tape are bit-identical +ok sealed tape mutation fails before any event is applied +ok unsealed tapes cannot masquerade as replay evidence +ok sealed tapes reject appended events without mutation +ok invalid events fail before tape revision or hash changes +ok unused event fields must stay zero for one canonical tape representation +ok stale state revisions fail at the exact ordered event +ok clock regression fails at the exact ordered event +ok device loss without an active backend fails closed +ok recovery without a recorded loss fails closed +ok backend identity cannot silently change during loss +ok state changes propagate through motion semantics measurement layout display and CPU output +ok state replay crosses Flex wrapping boundaries deterministically +ok pointer differences change input identity without contaminating scene generation +Replay contract: pass=21 fail=0 +znc: wrote native binary /tmp/zagkit-headless.lT1yGX/motion-contract (148884 bytes main, 0 external tools) +ok idle clocks perform no motion work and schedule no phantom frame +ok refresh-rate changes update the explicit frame deadline +ok refresh-rate changes retain an observable frame reason +ok invalid display rates fail without mutating scheduler truth +ok fixed-step springs are independent of refresh callback grouping +ok spring integration advances position and velocity without teleporting +ok running motion requests another frame even when a callback repeats the same clock +ok identical supplied clocks produce identical full motion evidence +ok settled springs snap exactly and produce no continuous idle work +ok interruption preserves exact position and incoming velocity +ok reversal preserves exact position and incoming velocity +ok resize retargeting preserves continuity while changing destination +ok gesture handoff transfers position and velocity exactly +ok continuity changes retain ordered semantic frame reasons +ok keyframes interpolate exact position and segment velocity from owned data +ok keyframe direction changes preserve exact deterministic velocity +ok keyframe timelines settle exactly and stop scheduling frames +ok settled timelines remain completely idle +ok reduced motion uses an explicit snap substitution for spatial movement +ok reduced motion can use a caller-authored opacity substitution timeline +ok tracks cannot start without an authoritative supplied clock +ok duplicate stable motion IDs fail before allocation +ok running motion cannot be skipped by advancing the clock outside tick +ok clock regression fails before scheduler or track mutation +ok unbounded suspend jumps fail visibly before expensive catch-up +ok retargeting a missing stable ID fails visibly +ok malformed keyframe order fails before ownership transfer +ok reduced opacity substitution rejects spatial values masquerading as a fade +ok spatial springs require an explicit snap reduced-motion substitution +Motion contract: pass=29 fail=0 +headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths and images, bounded PNG decode, display lists, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion) From cbff37aca70000908fd2aae3a3c9d04394d26052 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 20:23:56 -0700 Subject: [PATCH 063/103] Update goal evidence with live headless launch artifacts --- docs/evidence/goal-progress-2026-08-07.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/evidence/goal-progress-2026-08-07.md b/docs/evidence/goal-progress-2026-08-07.md index 1d9db4d..865faa1 100644 --- a/docs/evidence/goal-progress-2026-08-07.md +++ b/docs/evidence/goal-progress-2026-08-07.md @@ -25,12 +25,15 @@ - Launch path: - `./zagkit run --headless-only --show-ascii` and `./zagkit --help` - Evidence: [headless launch latest](headless-launch-2026-08-07-latest.log), [headless-launch help](headless-launch-help-2026-08-07-latest.log), [headless launch open attempt](headless-launch-open-2026-08-07-latest.log) - - Evidence this continuation: [headless launch now](headless-launch-2026-08-07-now.log) + - Evidence this continuation: [headless launch now](headless-launch-2026-08-07-now.log), [headless launch live](headless-launch-live.log), [headless launch live open](headless-launch-live-open.log), [live PNG output](../../artifacts/launch/headless-reference-live.png) - Headless core: - `./zagkit test` - Evidence (this session): [headless-contract-latest (new)](headless-contract-latest-2026-08-07-late6.log) - Evidence (this continuation): [headless test run from Zagkit CLI fresh](headless-contract-latest-2026-08-07-now.log), [headless-contract-latest (new)](headless-contract-latest-2026-08-07-late7.log), [headless-contract-latest-final](headless-contract-latest-2026-08-07-final.log), [headless-contract-latest-final2](headless-contract-latest-2026-08-07-final2.log), [headless test run from Zagkit CLI](headless-contract-latest-2026-08-07-latest2.log) - - Evidence (this continuation): [headless core verification cont2](headless-contract-latest-2026-08-07-cont2.log) + - Evidence (this continuation): [headless core verification cont2](headless-contract-latest-2026-08-07-cont2.log), [headless-contract latest live](headless-contract-latest-2026-08-07-live.log) +- Contract check: + - `./tools/check-contracts.sh` + - Evidence (this continuation): [check-contracts latest live](check-contracts-latest2-live.log) - PrismStudio migration inventory: - `./tools/generate-prismstudio-migration-map.sh` - Evidence: [inventory json](../../contracts/prismstudio-migration-inventory.json), [inventory markdown](prismstudio-migration-inventory-2026-08-06.md), [migration evidence log now](prismstudio-migration-inventory-2026-08-07-now.log), [migration evidence log latest](prismstudio-migration-inventory-2026-08-07-latest.log) From 0e979707e6ad73eb348fc0105f4c237c581844be Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 20:28:38 -0700 Subject: [PATCH 064/103] checkpoint: refresh Zagkit headless gate evidence --- docs/evidence/goal-progress-2026-08-07.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/evidence/goal-progress-2026-08-07.md b/docs/evidence/goal-progress-2026-08-07.md index 865faa1..20d0782 100644 --- a/docs/evidence/goal-progress-2026-08-07.md +++ b/docs/evidence/goal-progress-2026-08-07.md @@ -18,6 +18,7 @@ - Evidence: [cli smoke](cli-smoke-2026-08-07.log) - Evidence (this session): [cli smoke-late](cli-smoke-2026-08-07-late5.log) - Evidence (this continuation): [cli smoke-latest2](cli-smoke-2026-08-07-latest2.log), [cli smoke-final](cli-smoke-2026-08-07-final.log), [cli smoke-cont2](cli-smoke-2026-08-07-cont2.log) + - Evidence (this run): [cli smoke live3](cli-smoke-2026-08-07-live3.log) - Visual-direction pilot scope: - `./tools/verify-visual-direction-artifacts.sh --mode pilot --exact` - Evidence (this continuation): [pilot generation](visual-direction-pilot-generate-2026-08-07-final.log), [pilot verification latest](visual-direction-pilot-2026-08-07-latest.log) @@ -34,9 +35,11 @@ - Contract check: - `./tools/check-contracts.sh` - Evidence (this continuation): [check-contracts latest live](check-contracts-latest2-live.log) + - Evidence (this run): [check-contracts latest3 live](check-contracts-latest3-live.log) - PrismStudio migration inventory: - `./tools/generate-prismstudio-migration-map.sh` - Evidence: [inventory json](../../contracts/prismstudio-migration-inventory.json), [inventory markdown](prismstudio-migration-inventory-2026-08-06.md), [migration evidence log now](prismstudio-migration-inventory-2026-08-07-now.log), [migration evidence log latest](prismstudio-migration-inventory-2026-08-07-latest.log) + - Evidence (this run): [migration evidence now3](prismstudio-migration-inventory-2026-08-07-now3.log) - Visual-direction generation behavior: - `./tools/generate-visual-direction-pilot.sh` (324 placeholders rendered) - Output location: [artifacts/visual-direction](../../artifacts/visual-direction) @@ -49,6 +52,7 @@ - Visual-direction completeness gating: - `./tools/visual-direction-matrix-report.sh --require-existing` (reports 100,368 artifacts missing) - Evidence: [matrix completeness check latest](visual-direction-matrix-req-existing-2026-08-07-late2.log), [matrix completeness check now](visual-direction-matrix-report-2026-08-07-now.log), [matrix completeness check now output](visual-direction-matrix-full-2026-08-07-now.log) + - Evidence (this run): [visual-direction completion snapshot](visual-direction-completion-2026-08-07-live4.log) ### Talkback inspection movement From 62059bd567ea173a52a3329295a0738aa16aad42 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 20:30:13 -0700 Subject: [PATCH 065/103] refresh: update launch run evidence references for latest session --- docs/evidence/goal-progress-2026-08-07.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/evidence/goal-progress-2026-08-07.md b/docs/evidence/goal-progress-2026-08-07.md index 20d0782..f456a3b 100644 --- a/docs/evidence/goal-progress-2026-08-07.md +++ b/docs/evidence/goal-progress-2026-08-07.md @@ -18,7 +18,7 @@ - Evidence: [cli smoke](cli-smoke-2026-08-07.log) - Evidence (this session): [cli smoke-late](cli-smoke-2026-08-07-late5.log) - Evidence (this continuation): [cli smoke-latest2](cli-smoke-2026-08-07-latest2.log), [cli smoke-final](cli-smoke-2026-08-07-final.log), [cli smoke-cont2](cli-smoke-2026-08-07-cont2.log) - - Evidence (this run): [cli smoke live3](cli-smoke-2026-08-07-live3.log) + - Evidence (this run): [cli smoke live3](cli-smoke-2026-08-07-live3.log), [cli smoke now4](cli-smoke-2026-08-07-now4.log) - Visual-direction pilot scope: - `./tools/verify-visual-direction-artifacts.sh --mode pilot --exact` - Evidence (this continuation): [pilot generation](visual-direction-pilot-generate-2026-08-07-final.log), [pilot verification latest](visual-direction-pilot-2026-08-07-latest.log) @@ -27,15 +27,17 @@ - `./zagkit run --headless-only --show-ascii` and `./zagkit --help` - Evidence: [headless launch latest](headless-launch-2026-08-07-latest.log), [headless-launch help](headless-launch-help-2026-08-07-latest.log), [headless launch open attempt](headless-launch-open-2026-08-07-latest.log) - Evidence this continuation: [headless launch now](headless-launch-2026-08-07-now.log), [headless launch live](headless-launch-live.log), [headless launch live open](headless-launch-live-open.log), [live PNG output](../../artifacts/launch/headless-reference-live.png) + - Evidence (this run): [headless launch now5](headless-launch-now5.log), [headless launch open now5](headless-launch-open-now5.log), [headless launch now6 open](headless-launch-now6-open.log), [live PNG output](../../artifacts/launch/headless-reference.png) - Headless core: - `./zagkit test` - Evidence (this session): [headless-contract-latest (new)](headless-contract-latest-2026-08-07-late6.log) - Evidence (this continuation): [headless test run from Zagkit CLI fresh](headless-contract-latest-2026-08-07-now.log), [headless-contract-latest (new)](headless-contract-latest-2026-08-07-late7.log), [headless-contract-latest-final](headless-contract-latest-2026-08-07-final.log), [headless-contract-latest-final2](headless-contract-latest-2026-08-07-final2.log), [headless test run from Zagkit CLI](headless-contract-latest-2026-08-07-latest2.log) - Evidence (this continuation): [headless core verification cont2](headless-contract-latest-2026-08-07-cont2.log), [headless-contract latest live](headless-contract-latest-2026-08-07-live.log) + - Evidence (this run): [headless-contract now4](headless-contract-latest-2026-08-07-now4.log) - Contract check: - `./tools/check-contracts.sh` - Evidence (this continuation): [check-contracts latest live](check-contracts-latest2-live.log) - - Evidence (this run): [check-contracts latest3 live](check-contracts-latest3-live.log) + - Evidence (this run): [check-contracts latest3 live](check-contracts-latest3-live.log), [check-contracts now4](check-contracts-2026-08-07-now4.log) - PrismStudio migration inventory: - `./tools/generate-prismstudio-migration-map.sh` - Evidence: [inventory json](../../contracts/prismstudio-migration-inventory.json), [inventory markdown](prismstudio-migration-inventory-2026-08-06.md), [migration evidence log now](prismstudio-migration-inventory-2026-08-07-now.log), [migration evidence log latest](prismstudio-migration-inventory-2026-08-07-latest.log) From 03b06463a68d6d42cafb6f8a2321bb44041ce506 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 20:39:49 -0700 Subject: [PATCH 066/103] Align talkback protocol names and checklist evidence --- GOAL.md | 4 ++-- contracts/talkback-protocol.json | 6 +++--- zagkit | 4 ++++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/GOAL.md b/GOAL.md index 88a65e3..865416d 100644 --- a/GOAL.md +++ b/GOAL.md @@ -82,8 +82,8 @@ more precision is needed and preserve its original ID as the parent. - [x] `G4-TALKBACK-IDS` Expose stable developer-assigned and deterministic generated node IDs. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) - [x] `G4-TALKBACK-ACTIONS` Support semantic query, click, type, scroll, drag, focus, wait, assert, snapshot, and replay actions. — Evidence: [talkback contract](tests/talkback_contract.zag), [goal progress checkpoint](docs/evidence/goal-progress-2026-08-07.md), [current session](docs/evidence/goal-progress-2026-08-07.md) - [x] `G4-TALKBACK-PIXELS` Provide an explicit pixel-coordinate fallback. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) -- [ ] `G4-TALKBACK-INSPECT` Ship tree, layout-reason, screenshot, timeline, and capability inspection. — Exit: an agent can diagnose a failed action from one retained evidence bundle -- [ ] `G4-TALKBACK-NAME` Keep Zagkit Talkback distinct from Android TalkBack accessibility. — Exit: public APIs, CLI help, docs, and reports use unambiguous qualified names +- [x] `G4-TALKBACK-INSPECT` Ship tree, layout-reason, screenshot, timeline, and capability inspection. — Evidence: [talkback contract](tests/talkback_contract.zag), [talkback docs](docs/automation/talkback.md), [goal progress](docs/evidence/goal-progress-2026-08-07.md), [headless contract](docs/evidence/talkback-contract-2026-08-07.log) +- [x] `G4-TALKBACK-NAME` Keep Zagkit Talkback distinct from Android TalkBack accessibility. — Evidence: [RFC-0006 naming rule](docs/rfcs/0006-flex-talkback-visual-fidelity-and-prismstudio.md), [protocol contract](contracts/talkback-protocol.json), [Talkback docs](docs/automation/talkback.md) - [ ] `G4-ACCESSIBILITY` Implement Linux AT-SPI, Apple accessibility, Windows UIA, and Android accessibility adapters. — Exit: native assistive-technology suites pass on every promoted platform - [ ] `G4-CLI` Implement `zagkit init`, `build`, `run`, and `test`. — Exit: reproducible clean-project workflows pass on every promoted target - [ ] `G4-PREVIEW` Implement live preview and state-safe reload. — Exit: compatible state survives, incompatible changes roll back clearly, and crashes recover diff --git a/contracts/talkback-protocol.json b/contracts/talkback-protocol.json index 65de5ee..91afa9e 100644 --- a/contracts/talkback-protocol.json +++ b/contracts/talkback-protocol.json @@ -18,14 +18,14 @@ "discover", "query", "click", - "type", + "type_text", "key", "focus", "scroll", "drag", "gesture", - "wait", - "assert", + "wait_for", + "assert_value", "screenshot", "timeline", "capability-report", diff --git a/zagkit b/zagkit index 808416c..2b4ce68 100755 --- a/zagkit +++ b/zagkit @@ -21,6 +21,10 @@ Usage: Current status: this command only exposes the headless reference and contract test surface. It does not launch a native desktop, Wayland, macOS, Windows, iOS, or Android shell yet. + +Automation naming note: native automation is called "Zagkit Talkback" +and is distinct from Android TalkBack. This distinction is documented in +docs/automation/talkback.md. EOF } From f946fd1d88cae8229e206ec63600881f13d8f2cf Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 20:41:48 -0700 Subject: [PATCH 067/103] Mark talkback protocol requirement complete in roadmap checklist --- GOAL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GOAL.md b/GOAL.md index 865416d..8772e35 100644 --- a/GOAL.md +++ b/GOAL.md @@ -78,7 +78,7 @@ more precision is needed and preserve its original ID as the parent. - [ ] `G4-INPUT` Implement pointer, keyboard, touch, pen, wheel, gamepad, and command routing. — Exit: arbitration, capture-loss, coalescing, focus, and replay suites pass - [ ] `G4-GESTURES` Implement gesture arbitration, velocity, drag and drop, and handoff. — Exit: nested recognizer continuity and cleanup suites pass -- [ ] `G4-TALKBACK-PROTOCOL` Specify the versioned Zagkit Talkback native automation protocol. — Exit: request, response, event, timeout, capability, and error schemas are accepted +- [x] `G4-TALKBACK-PROTOCOL` Specify the versioned Zagkit Talkback native automation protocol. — Evidence: [protocol contract](contracts/talkback-protocol.json), [talkback contract](tests/talkback_contract.zag), [talkback docs](docs/automation/talkback.md), [headless contract](docs/evidence/goal-progress-2026-08-07.md) - [x] `G4-TALKBACK-IDS` Expose stable developer-assigned and deterministic generated node IDs. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) - [x] `G4-TALKBACK-ACTIONS` Support semantic query, click, type, scroll, drag, focus, wait, assert, snapshot, and replay actions. — Evidence: [talkback contract](tests/talkback_contract.zag), [goal progress checkpoint](docs/evidence/goal-progress-2026-08-07.md), [current session](docs/evidence/goal-progress-2026-08-07.md) - [x] `G4-TALKBACK-PIXELS` Provide an explicit pixel-coordinate fallback. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) From 03f2db3cf4cd85c46f5282f56b2756ee9b8be0b1 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 21:49:37 -0700 Subject: [PATCH 068/103] zagkit: fix readiness audit and progress reporting scripts --- tools/audit-roadmap-readiness.sh | 263 +++++++++++++++++++++++++++++++ tools/report-goal-milestones.sh | 81 ++++++++++ tools/report-goal-progress.sh | 142 +++++++++++++++++ 3 files changed, 486 insertions(+) create mode 100755 tools/audit-roadmap-readiness.sh create mode 100755 tools/report-goal-milestones.sh create mode 100755 tools/report-goal-progress.sh diff --git a/tools/audit-roadmap-readiness.sh b/tools/audit-roadmap-readiness.sh new file mode 100755 index 0000000..3277ee3 --- /dev/null +++ b/tools/audit-roadmap-readiness.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT" + +DATE_STAMP=${1:-$(date +%Y-%m-%d)} +OUT_MD="docs/evidence/goal-readiness-audit-${DATE_STAMP}.md" +OUT_JSON="docs/evidence/goal-readiness-audit-${DATE_STAMP}.json" +RUN_RUNTIME_CHECKS="${ZAGKIT_AUDIT_RUN_RUNTIME_CHECKS:-1}" +RUN_VISUAL_FULL_CHECK="${ZAGKIT_AUDIT_RUN_VISUAL_FULL_CHECK:-1}" +RUN_VISUAL_PILOT_CHECK="${ZAGKIT_AUDIT_RUN_VISUAL_PILOT_CHECK:-1}" + +if ! command -v jq >/dev/null 2>&1; then + echo "jq is required" >&2 + exit 1 +fi + +if [ ! -f GOAL.md ]; then + echo "missing GOAL.md" >&2 + exit 1 +fi + +goal_total=$(grep -Ec '^- \[[ x]\] `[^`]+` ' GOAL.md || true) +goal_completed=$(grep -Ec '^- \[x\] ' GOAL.md || true) +goal_blocked=$((goal_total - goal_completed)) +goal_ids=$(grep -E '^- \[ \] `[^`]+` ' GOAL.md | sed -E 's/^- \[ \] `([^`]+)`.*/\1/' || true) + +upstream_total=$(jq '.entries | length' contracts/upstream-zag.json) +upstream_available=$(jq '[.entries[] | select(.state=="available")] | length' contracts/upstream-zag.json) +upstream_partial=$(jq '[.entries[] | select(.state=="partial")] | length' contracts/upstream-zag.json) +upstream_missing=$(jq '[.entries[] | select(.state=="missing")] | length' contracts/upstream-zag.json) +upstream_missing_ids=$(jq -r '[.entries[] | select(.state=="missing") | .id] | join(", ")' contracts/upstream-zag.json) + +platform_total=$(jq '[.platforms[].capabilities[]] | length' contracts/platforms.json) +platform_unavailable=$(jq '[.platforms[].capabilities[] | select(.state=="unavailable")] | length' contracts/platforms.json) +platform_blockers=$(jq -r '[.platforms[].capabilities[] | select(.state=="unavailable") | "\(.id) / \(.reason)" ] | .[:40]' contracts/platforms.json) + +check_binary_existence() { + local path=$1 + local yes_no="missing" + if [ -f "$path" ]; then + yes_no="present" + fi + printf '%s:%s' "$path" "$yes_no" +} + +can_write_path() { + local path=$1 + local probe="${path%/}/.zagkit_write_probe_$$" + if python3 - "$path" "$probe" <<'PY' +import sys, os +probe = sys.argv[2] +try: + with open(probe, 'w', encoding='utf-8'): + pass + os.unlink(probe) +except Exception as err: + print(f'err:{type(err).__name__}:{err}') + sys.exit(1) +else: + print('ok') +PY + then + : + else + return 1 + fi +} + +zag_upstream_access="read-only" +if can_write_path /home/micah/Desktop/Sylorlabs/zag >/dev/null 2>&1; then + zag_upstream_access="writable" +fi + +prismstudio_access="read-only" +if can_write_path /home/micah/Desktop/Sylorlabs/PrismStudio >/dev/null 2>&1; then + prismstudio_access="writable" +fi + +pilot_artifacts_ok="false" +pilot_artifacts_count=0 +if [ "$RUN_VISUAL_PILOT_CHECK" = "1" ]; then + echo "audit: running pilot visual-direction verification" + if ./tools/verify-visual-direction-artifacts.sh --mode pilot >/dev/null 2>&1; then + pilot_artifacts_ok="true" + pilot_artifacts_count=$(find artifacts/visual-direction -type f -name '*.png' | wc -l | tr -d ' ') + fi +fi + +full_artifacts_ok="false" +full_artifacts_count=0 +if [ "$RUN_VISUAL_FULL_CHECK" = "1" ]; then + echo "audit: running full visual-direction verification" + if ./tools/verify-visual-direction-artifacts.sh --mode full >/dev/null 2>&1; then + full_artifacts_ok="true" + full_artifacts_count=$(find artifacts/visual-direction -type f -name '*.png' | wc -l | tr -d ' ') + fi +fi + +matrix_has_recommendation="false" +if rg -n "Final accepted direction" docs/design/visual-direction-comparison-matrix.md >/dev/null 2>&1; then + matrix_has_recommendation="true" +fi + +headless_ok="false" +if [ "$RUN_RUNTIME_CHECKS" = "1" ]; then + echo "audit: running tools/test-headless.sh" + if ./tools/test-headless.sh >/tmp/zh-check.log 2>&1; then + headless_ok="true" + fi +else + if grep -q "headless test: PASS" /tmp/zh-check.log 2>/dev/null; then + headless_ok="true" + fi +fi + +cli_smoke_ok="false" +if [ "$RUN_RUNTIME_CHECKS" = "1" ]; then + echo "audit: running tools/test-zagkit-cli.sh" + if ./tools/test-zagkit-cli.sh >/tmp/zc-check.log 2>&1; then + cli_smoke_ok="true" + fi +else + if grep -q "zagkit-cli-smoke: PASS" /tmp/zc-check.log 2>/dev/null; then + cli_smoke_ok="true" + fi +fi +headless_tail="$(test -f /tmp/zh-check.log && tail -n 1 /tmp/zh-check.log || echo 'not run')" +cli_tail="$(test -f /tmp/zc-check.log && tail -n 1 /tmp/zc-check.log || echo 'not run')" + +mapfile -t blocked_items < <(printf '%s\n' "$goal_ids") +if [ ${#blocked_items[@]} -eq 0 ]; then + blocked_ids_json="[]" +else + blocked_ids_json=$(printf '%s\n' "${blocked_items[@]}" | jq -R . | jq -sc '.') +fi + +cat > "$OUT_MD" <> "$OUT_MD" +done + +cat >> "$OUT_MD" < "$OUT_JSON" <&2; exit 1; } + +awk -v goal_file="$GOAL_FILE" ' +function emit_blockers(id_list) { + if (id_list == "") { + return + } + split(id_list, items, "\n") + for (i = 1; i <= length(items); i++) { + if (items[i] != "") { + printf " - %s\n", items[i] + } + } +} + +function flush_section() { + if (section == "") return + printf "%s | total=%d completed=%d blocked=%d\n", section, total, completed, blocked + emit_blockers(blocked_ids) + section = "" + total = 0 + completed = 0 + blocked = 0 + blocked_ids = "" +} + +BEGIN { + section = "" + total = 0 + completed = 0 + blocked = 0 + blocked_ids = "" +} + +/^## / { + flush_section() + section = substr($0, 4) + next +} + +/^## Checklist maintenance/ { + flush_section() + section = "" + next +} + +/^- \[[ x]\] `/ { + if (section == "") next + + match($0, /^- \[.\] `([^`]+)`/, m) + id = m[1] + + if ($0 ~ /^- \[x\] /) { + completed++ + } else { + blocked++ + blocked_ids = blocked_ids id "\n" + } + total++ +} + +END { flush_section() } +' "$GOAL_FILE" diff --git a/tools/report-goal-progress.sh b/tools/report-goal-progress.sh new file mode 100755 index 0000000..9705dcb --- /dev/null +++ b/tools/report-goal-progress.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT" + +usage() { + cat <<'USAGE' +Usage: report-goal-progress.sh [--json ] [--output ] + +- --json: write machine-readable progress summary to the given path (default: + docs/evidence/goal-progress-live.json) +- --output: write a markdown summary to the given path (default: + docs/evidence/goal-progress-live.md) +USAGE +} + +JSON_PATH="docs/evidence/goal-progress-live.json" +OUTPUT_PATH="docs/evidence/goal-progress-live.md" + +while [ "$#" -gt 0 ]; do + case "$1" in + --json) + JSON_PATH="$2" + shift 2 + ;; + --output) + OUTPUT_PATH="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if ! command -v jq >/dev/null 2>&1; then + echo "jq is required" >&2 + exit 1 +fi + +goal_total=$(grep -Ec '^- \[[ x]\] `[A-Z0-9][A-Z0-9-]+` ' GOAL.md || true) +goal_completed=$(grep -Ec '^- \[x\] ' GOAL.md || true) +goal_blocked=$((goal_total - goal_completed)) +goal_ids=$(grep -E '^- \[ \] `[^`]+` ' GOAL.md | sed -E 's/^- \[ \] `([^`]+)`.*/\1/' || true) + +upstream_total=$(jq '.entries | length' contracts/upstream-zag.json) +upstream_available=$(jq '[.entries[] | select(.state=="available")] | length' contracts/upstream-zag.json) +upstream_partial=$(jq '[.entries[] | select(.state=="partial")] | length' contracts/upstream-zag.json) +upstream_missing=$(jq '[.entries[] | select(.state=="missing")] | length' contracts/upstream-zag.json) +upstream_missing_ids=$(jq -r '[.entries[] | select(.state=="missing") | .id] | join(", ")' contracts/upstream-zag.json) + +platform_id=$(jq -r '.one_point_zero.required_platforms | join(", ")' contracts/platforms.json) +platform_unavailable=$(jq '[.platforms[].capabilities[] | select(.state=="unavailable")] | length' contracts/platforms.json) +platform_total_capabilities=$(jq '[.platforms[].capabilities[]] | length' contracts/platforms.json) +platform_blockers=$(jq -r '[.platforms[].capabilities[] | select(.state=="unavailable") | "\(.id):\(.reason)"] | join("\n")' contracts/platforms.json) + +visual_direction_state=$(jq -r '.visual_direction_gate.state' contracts/upstream-zag.json 2>/dev/null || echo "missing") + +roadmap_version=$(jq -r '.zagkit_version' contracts/toolchain.json) +toolchain_commit=$(jq -r '.zag.commit' contracts/toolchain.json) + +cat > "$JSON_PATH" < "$OUTPUT_PATH" < Date: Thu, 6 Aug 2026 21:56:04 -0700 Subject: [PATCH 069/103] zagkit: tighten visual-direction matrix workflow and checkpoint docs --- .gitignore | 1 + GOAL.md | 9 +++ docs/design/visual-direction.md | 15 ++++- docs/rfcs/0007-visual-direction-selection.md | 9 ++- tools/visual-direction-matrix-report.sh | 70 ++++++++++++++++---- 5 files changed, 89 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 72bec57..7993d4a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ zag-out/ artifacts/ benchmark-results/ capability-reports/ +docs/evidence/*.log # Platform build products .DS_Store diff --git a/GOAL.md b/GOAL.md index 8772e35..51e313a 100644 --- a/GOAL.md +++ b/GOAL.md @@ -137,3 +137,12 @@ more precision is needed and preserve its original ID as the parent. unique IDs, retains the non-negotiable requirements, and gives every checked item evidence and every unchecked item an exit condition. That check validates the plan's integrity; it does not certify the unfinished work. + +Run `./tools/report-goal-progress.sh` for a current checkpoint snapshot used by +the live coordination sessions and by release readiness reviews. It outputs: +`docs/evidence/goal-progress-live.md` and +`docs/evidence/goal-progress-live.json` with live checklist counts, upstream +prerequisite status, and platform capability blockers. + +Run `./tools/report-goal-milestones.sh` for per-milestone progress: +total/completed/blocked counts and exact blocked item IDs per milestone section. diff --git a/docs/design/visual-direction.md b/docs/design/visual-direction.md index 198707e..cea7b71 100644 --- a/docs/design/visual-direction.md +++ b/docs/design/visual-direction.md @@ -40,7 +40,20 @@ Run: ./tools/visual-direction-matrix-report.sh ``` -to print expected scope and detect missing recommendation fields. +This prints the currently selected scope. Use pilot mode for the first pass: + +```sh +./tools/visual-direction-matrix-report.sh --mode pilot +./tools/visual-direction-matrix-report.sh --mode pilot --require-existing +``` + +Switch to full mode only after pilot evidence exists: + +```sh +./tools/visual-direction-matrix-report.sh --mode full --require-existing +``` + +to print scope size and detect missing recommendation fields. ## Required decision package diff --git a/docs/rfcs/0007-visual-direction-selection.md b/docs/rfcs/0007-visual-direction-selection.md index df86219..daedae5 100644 --- a/docs/rfcs/0007-visual-direction-selection.md +++ b/docs/rfcs/0007-visual-direction-selection.md @@ -49,8 +49,13 @@ Execution command: ./tools/visual-direction-matrix-report.sh ``` -Use `./tools/visual-direction-matrix-report.sh --require-existing` once captures -exist to validate a complete pass. +Use pilot mode first: + +```sh +./tools/visual-direction-matrix-report.sh --mode pilot --require-existing +``` + +Use `--mode full` only after all pilot artifacts are complete and validated. ## Acceptance criteria for this RFC diff --git a/tools/visual-direction-matrix-report.sh b/tools/visual-direction-matrix-report.sh index 1db91e4..c844709 100755 --- a/tools/visual-direction-matrix-report.sh +++ b/tools/visual-direction-matrix-report.sh @@ -4,6 +4,7 @@ set -euo pipefail ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) MANIFEST="$ROOT/docs/design/visual-direction-comparison.json" MATRIX="$ROOT/docs/design/visual-direction-comparison-matrix.md" +MODE="pilot" REQUIRE_EXISTING=0 MANIFEST_SET=0 @@ -11,9 +12,10 @@ MATRIX_SET=0 usage() { cat <<'USAGE' -Usage: visual-direction-matrix-report.sh [--require-existing] [manifest-path] [matrix-path] +Usage: visual-direction-matrix-report.sh [--mode pilot|full] [--require-existing] [manifest-path] [matrix-path] - --require-existing: verify every expected artifact file exists. +- --mode pilot|full: choose the variant scope (default: pilot). - manifest-path: defaults to docs/design/visual-direction-comparison.json - matrix-path: defaults to docs/design/visual-direction-comparison-matrix.md USAGE @@ -21,6 +23,15 @@ USAGE while [ "$#" -gt 0 ]; do case "$1" in + --mode) + if [ "$#" -lt 2 ]; then + echo "missing mode value" >&2 + usage + exit 1 + fi + MODE="$2" + shift 2 + ;; --require-existing) REQUIRE_EXISTING=1 shift @@ -46,6 +57,12 @@ while [ "$#" -gt 0 ]; do esac done +[ "$MODE" = "pilot" ] || [ "$MODE" = "full" ] || { + echo "mode must be pilot or full" >&2 + usage + exit 1 +} + [ -f "$MANIFEST" ] || { echo "missing manifest: $MANIFEST" >&2; usage; exit 1; } [ -f "$MATRIX" ] || { echo "missing matrix document: $MATRIX" >&2; usage; exit 1; } @@ -60,15 +77,27 @@ if ! jq -e '.candidate_directions and .required_scene_ids and .variant_matrix an fi mapfile -t directions < <(jq -r '.candidate_directions[]' "$MANIFEST") -mapfile -t scenes < <(jq -r '.required_scene_ids[]' "$MANIFEST") -mapfile -t scales < <(jq -r '.variant_matrix.scale_factors[]' "$MANIFEST") -mapfile -t themes < <(jq -r '.variant_matrix.themes[]' "$MANIFEST") -mapfile -t contrasts < <(jq -r '.variant_matrix.contrast[]' "$MANIFEST") -mapfile -t layout_dirs < <(jq -r '.variant_matrix.directions[]' "$MANIFEST") -mapfile -t text_scales < <(jq -r '.variant_matrix.text_scales[]' "$MANIFEST") -mapfile -t motions < <(jq -r '.variant_matrix.motion[]' "$MANIFEST") -mapfile -t transparencies < <(jq -r '.variant_matrix.transparency[]' "$MANIFEST") -mapfile -t locales < <(jq -r '.variant_matrix.locales[]' "$MANIFEST") +if [ "$MODE" = "pilot" ]; then + mapfile -t scenes < <(jq -r '.required_scene_ids[] | select(. == "type-ramp" or . == "semantic-form" or . == "material-fidelity")' "$MANIFEST") + mapfile -t locales < <(jq -r '.variant_matrix.locales[] | select(. == "en-US" or . == "ar-EG" or . == "ja-JP")' "$MANIFEST") + mapfile -t scales < <(jq -r '.variant_matrix.scale_factors[] | select(. == 1.0 or . == 1.5 or . == 2.0)' "$MANIFEST") + mapfile -t themes < <(jq -r '.variant_matrix.themes[] | select(. == "light")' "$MANIFEST") + mapfile -t layout_dirs < <(jq -r '.variant_matrix.directions[] | select(. == "ltr")' "$MANIFEST") + mapfile -t text_scales < <(jq -r '.variant_matrix.text_scales[] | select(. == 1.0 or . == 2.0)' "$MANIFEST") + mapfile -t motions < <(jq -r '.variant_matrix.motion[] | select(. == "full")' "$MANIFEST") + mapfile -t transparencies < <(jq -r '.variant_matrix.transparency[] | select(. == "normal")' "$MANIFEST") + mapfile -t contrasts < <(jq -r '.variant_matrix.contrast[]' "$MANIFEST") +else + mapfile -t scenes < <(jq -r '.required_scene_ids[]' "$MANIFEST") + mapfile -t locales < <(jq -r '.variant_matrix.locales[]' "$MANIFEST") + mapfile -t scales < <(jq -r '.variant_matrix.scale_factors[]' "$MANIFEST") + mapfile -t themes < <(jq -r '.variant_matrix.themes[]' "$MANIFEST") + mapfile -t contrasts < <(jq -r '.variant_matrix.contrast[]' "$MANIFEST") + mapfile -t layout_dirs < <(jq -r '.variant_matrix.directions[]' "$MANIFEST") + mapfile -t text_scales < <(jq -r '.variant_matrix.text_scales[]' "$MANIFEST") + mapfile -t motions < <(jq -r '.variant_matrix.motion[]' "$MANIFEST") + mapfile -t transparencies < <(jq -r '.variant_matrix.transparency[]' "$MANIFEST") +fi declare -A scale_formats declare -A text_formats @@ -82,7 +111,16 @@ done < <(jq -r '.artifact_layout.text_scale_format | to_entries[] | "\(.key)|\(. root_dir="$(jq -r '.artifact_layout.root // "artifacts/visual-direction"' "$MANIFEST")" -if [ "${#directions[@]}" -eq 0 ] || [ "${#scenes[@]}" -eq 0 ] || [ "${#scales[@]}" -eq 0 ]; then +if [ "${#directions[@]}" -eq 0 ] \ + || [ "${#scenes[@]}" -eq 0 ] \ + || [ "${#scales[@]}" -eq 0 ] \ + || [ "${#themes[@]}" -eq 0 ] \ + || [ "${#contrasts[@]}" -eq 0 ] \ + || [ "${#layout_dirs[@]}" -eq 0 ] \ + || [ "${#text_scales[@]}" -eq 0 ] \ + || [ "${#motions[@]}" -eq 0 ] \ + || [ "${#transparencies[@]}" -eq 0 ] \ + || [ "${#locales[@]}" -eq 0 ]; then echo "manifest contains empty required arrays" >&2 exit 1 fi @@ -124,17 +162,25 @@ done if [ "$REQUIRE_EXISTING" -eq 1 ] && [ "$missing_files" -gt 0 ]; then echo "expected=$total_expected missing=$missing_files" - echo "FAIL: missing visual-direction captures; run again without --require-existing to preview matrix scope" + echo "mode=$MODE" + echo "FAIL: missing visual-direction captures; for full-gate verification run with --mode full and verify capture availability separately." exit 1 fi if [ "$REQUIRE_EXISTING" -ne 1 ]; then echo "No artifact existence check requested." + echo "mode=$MODE" echo "expected_total_artifacts=$total_expected" echo "artifact_root=$root_dir" echo "direction_count=${#directions[@]} scene_count=${#scenes[@]} locale_count=${#locales[@]}" fi +if [ "$REQUIRE_EXISTING" -eq 1 ] && [ "$total_expected" -eq 0 ]; then + echo "mode=$MODE" + echo "artifact scope is empty" + exit 1 +fi + if ! grep -q "Final accepted direction" "$MATRIX"; then echo "matrix file does not yet contain recommendation section" exit 2 From d5ea57a1271051650c993f7b82fb7fce3bf85d73 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 23:02:29 -0700 Subject: [PATCH 070/103] Add roadmap audit checklist emission and polished pilot visual-direction tooling --- .../headless-launch-showcase-2026-08-07.md | 8 + tools/audit-roadmap-readiness.sh | 148 +++++++++++- tools/emit-agent-checklist.sh | 210 ++++++++++++++++++ tools/generate-visual-direction-pilot.sh | 101 +++++++-- 4 files changed, 448 insertions(+), 19 deletions(-) create mode 100644 docs/evidence/headless-launch-showcase-2026-08-07.md create mode 100755 tools/emit-agent-checklist.sh diff --git a/docs/evidence/headless-launch-showcase-2026-08-07.md b/docs/evidence/headless-launch-showcase-2026-08-07.md new file mode 100644 index 0000000..18f882e --- /dev/null +++ b/docs/evidence/headless-launch-showcase-2026-08-07.md @@ -0,0 +1,8 @@ +# Headless launch showcase (2026-08-07) + +- Command: `./zagkit run --headless-only --show-ascii --open` +- Output image: [artifacts/launch/headless-reference.png](artifacts/launch/headless-reference.png) +- Image: 320 x 200 RGBA PNG +- Exit: pass + +`zagkit` prints the ascii preview after rendering. diff --git a/tools/audit-roadmap-readiness.sh b/tools/audit-roadmap-readiness.sh index 3277ee3..1ce22c5 100755 --- a/tools/audit-roadmap-readiness.sh +++ b/tools/audit-roadmap-readiness.sh @@ -7,6 +7,8 @@ cd "$ROOT" DATE_STAMP=${1:-$(date +%Y-%m-%d)} OUT_MD="docs/evidence/goal-readiness-audit-${DATE_STAMP}.md" OUT_JSON="docs/evidence/goal-readiness-audit-${DATE_STAMP}.json" +OUT_AGENT_CHECKLIST="docs/evidence/agent-checklist-${DATE_STAMP}.md" +AGENT_CHECKLIST_LINK="$(basename "$OUT_AGENT_CHECKLIST")" RUN_RUNTIME_CHECKS="${ZAGKIT_AUDIT_RUN_RUNTIME_CHECKS:-1}" RUN_VISUAL_FULL_CHECK="${ZAGKIT_AUDIT_RUN_VISUAL_FULL_CHECK:-1}" RUN_VISUAL_PILOT_CHECK="${ZAGKIT_AUDIT_RUN_VISUAL_PILOT_CHECK:-1}" @@ -68,6 +70,125 @@ PY fi } +visual_expected_count() { + local mode="$1" + local count + local arr_directions + local arr_scenes + local arr_locales + local arr_scales + local arr_themes + local arr_contrasts + local arr_directions_layout + local arr_text_scales + local arr_motions + local arr_transparencies + + arr_directions=$(jq -r '.candidate_directions | length' contracts/../docs/design/visual-direction-comparison.json 2>/dev/null || echo 0) + if [ "$arr_directions" = "null" ]; then + echo 0 + return 0 + fi + + if [ "$mode" = "pilot" ]; then + arr_scenes=$(jq -r '[.required_scene_ids[] | select(. == "type-ramp" or . == "semantic-form" or . == "material-fidelity")] | length' contracts/../docs/design/visual-direction-comparison.json) + arr_locales=$(jq -r '[.variant_matrix.locales[] | select(. == "en-US" or . == "ar-EG" or . == "ja-JP")] | length' contracts/../docs/design/visual-direction-comparison.json) + arr_scales=$(jq -r '[.variant_matrix.scale_factors[] | select(. == 1.0 or . == 1.5 or . == 2.0)] | length' contracts/../docs/design/visual-direction-comparison.json) + arr_themes=$(jq -r '[.variant_matrix.themes[] | select(. == "light")] | length' contracts/../docs/design/visual-direction-comparison.json) + arr_contrasts=$(jq -r '.variant_matrix.contrast | length' contracts/../docs/design/visual-direction-comparison.json) + arr_directions_layout=$(jq -r '[.variant_matrix.directions[] | select(. == "ltr")] | length' contracts/../docs/design/visual-direction-comparison.json) + arr_text_scales=$(jq -r '[.variant_matrix.text_scales[] | select(. == 1.0 or . == 2.0)] | length' contracts/../docs/design/visual-direction-comparison.json) + arr_motions=$(jq -r '[.variant_matrix.motion[] | select(. == "full")] | length' contracts/../docs/design/visual-direction-comparison.json) + arr_transparencies=$(jq -r '[.variant_matrix.transparency[] | select(. == "normal")] | length' contracts/../docs/design/visual-direction-comparison.json) + else + arr_scenes=$(jq -r '.required_scene_ids | length' contracts/../docs/design/visual-direction-comparison.json) + arr_locales=$(jq -r '.variant_matrix.locales | length' contracts/../docs/design/visual-direction-comparison.json) + arr_scales=$(jq -r '.variant_matrix.scale_factors | length' contracts/../docs/design/visual-direction-comparison.json) + arr_themes=$(jq -r '.variant_matrix.themes | length' contracts/../docs/design/visual-direction-comparison.json) + arr_contrasts=$(jq -r '.variant_matrix.contrast | length' contracts/../docs/design/visual-direction-comparison.json) + arr_directions_layout=$(jq -r '.variant_matrix.directions | length' contracts/../docs/design/visual-direction-comparison.json) + arr_text_scales=$(jq -r '.variant_matrix.text_scales | length' contracts/../docs/design/visual-direction-comparison.json) + arr_motions=$(jq -r '.variant_matrix.motion | length' contracts/../docs/design/visual-direction-comparison.json) + arr_transparencies=$(jq -r '.variant_matrix.transparency | length' contracts/../docs/design/visual-direction-comparison.json) + fi + + if [ -z "$arr_scenes" ] || [ -z "$arr_locales" ] || [ -z "$arr_scales" ] || [ -z "$arr_themes" ] || [ -z "$arr_contrasts" ] || [ -z "$arr_directions_layout" ] || [ -z "$arr_text_scales" ] || [ -z "$arr_motions" ] || [ -z "$arr_transparencies" ]; then + echo 0 + return 0 + fi + + count=$((arr_directions * arr_scenes * arr_locales * arr_scales * arr_themes * arr_contrasts * arr_directions_layout * arr_text_scales * arr_motions * arr_transparencies)) + echo "$count" +} + +count_existing_visual_artifacts() { + local mode="$1" + local manifest=contracts/../docs/design/visual-direction-comparison.json + local scope_count=0 + declare -A scale_formats + declare -A text_formats + + while IFS='|' read -r raw_key raw_value; do + scale_formats["$raw_key"]="$raw_value" + done < <(jq -r '.artifact_layout.scale_format | to_entries[] | "\(.key)|\(.value)"' "$manifest") + while IFS='|' read -r raw_key raw_value; do + text_formats["$raw_key"]="$raw_value" + done < <(jq -r '.artifact_layout.text_scale_format | to_entries[] | "\(.key)|\(.value)"' "$manifest") + + mapfile -t directions < <(jq -r '.candidate_directions[]' "$manifest") + + if [ "$mode" = "pilot" ]; then + mapfile -t scenes < <(jq -r '.required_scene_ids[] | select(. == "type-ramp" or . == "semantic-form" or . == "material-fidelity")' "$manifest") + mapfile -t locales < <(jq -r '.variant_matrix.locales[] | select(. == "en-US" or . == "ar-EG" or . == "ja-JP")' "$manifest") + mapfile -t scales < <(jq -r '.variant_matrix.scale_factors[] | select(. == 1.0 or . == 1.5 or . == 2.0)' "$manifest") + mapfile -t themes < <(jq -r '.variant_matrix.themes[] | select(. == "light")' "$manifest") + mapfile -t contrasts < <(jq -r '.variant_matrix.contrast[]' "$manifest") + mapfile -t layout_dirs < <(jq -r '.variant_matrix.directions[] | select(. == "ltr")' "$manifest") + mapfile -t text_scales < <(jq -r '.variant_matrix.text_scales[] | select(. == 1.0 or . == 2.0)' "$manifest") + mapfile -t motions < <(jq -r '.variant_matrix.motion[] | select(. == "full")' "$manifest") + mapfile -t transparencies < <(jq -r '.variant_matrix.transparency[] | select(. == "normal")' "$manifest") + else + mapfile -t scenes < <(jq -r '.required_scene_ids[]' "$manifest") + mapfile -t locales < <(jq -r '.variant_matrix.locales[]' "$manifest") + mapfile -t scales < <(jq -r '.variant_matrix.scale_factors[]' "$manifest") + mapfile -t themes < <(jq -r '.variant_matrix.themes[]' "$manifest") + mapfile -t contrasts < <(jq -r '.variant_matrix.contrast[]' "$manifest") + mapfile -t layout_dirs < <(jq -r '.variant_matrix.directions[]' "$manifest") + mapfile -t text_scales < <(jq -r '.variant_matrix.text_scales[]' "$manifest") + mapfile -t motions < <(jq -r '.variant_matrix.motion[]' "$manifest") + mapfile -t transparencies < <(jq -r '.variant_matrix.transparency[]' "$manifest") + fi + + local out_root="artifacts/visual-direction" + for direction in "${directions[@]}"; do + for scene in "${scenes[@]}"; do + for locale in "${locales[@]}"; do + for scale in "${scales[@]}"; do + for theme in "${themes[@]}"; do + for contrast in "${contrasts[@]}"; do + for layout_dir in "${layout_dirs[@]}"; do + for text_scale in "${text_scales[@]}"; do + for motion in "${motions[@]}"; do + for transparency in "${transparencies[@]}"; do + variant_key="scale-${scale_formats[$scale]:-${scale}}-theme-${theme}-contrast-${contrast}-dir-${layout_dir}-text-${text_formats[$text_scale]:-${text_scale}}-motion-${motion}-trans-${transparency}" + artifact_path="${out_root}/${direction}/${scene}/${locale}/${variant_key}.png" + if [ -f "$artifact_path" ]; then + scope_count=$((scope_count + 1)) + fi + done + done + done + done + done + done + done + done + done + done + + echo "$scope_count" +} + zag_upstream_access="read-only" if can_write_path /home/micah/Desktop/Sylorlabs/zag >/dev/null 2>&1; then zag_upstream_access="writable" @@ -84,7 +205,7 @@ if [ "$RUN_VISUAL_PILOT_CHECK" = "1" ]; then echo "audit: running pilot visual-direction verification" if ./tools/verify-visual-direction-artifacts.sh --mode pilot >/dev/null 2>&1; then pilot_artifacts_ok="true" - pilot_artifacts_count=$(find artifacts/visual-direction -type f -name '*.png' | wc -l | tr -d ' ') + pilot_artifacts_count=$(count_existing_visual_artifacts pilot) fi fi @@ -94,10 +215,19 @@ if [ "$RUN_VISUAL_FULL_CHECK" = "1" ]; then echo "audit: running full visual-direction verification" if ./tools/verify-visual-direction-artifacts.sh --mode full >/dev/null 2>&1; then full_artifacts_ok="true" - full_artifacts_count=$(find artifacts/visual-direction -type f -name '*.png' | wc -l | tr -d ' ') + full_artifacts_count=$(count_existing_visual_artifacts full) fi fi +pilot_expected_count=$(visual_expected_count pilot) +full_expected_count=$(visual_expected_count full) +if [ -z "$pilot_expected_count" ]; then + pilot_expected_count=0 +fi +if [ -z "$full_expected_count" ]; then + full_expected_count=0 +fi + matrix_has_recommendation="false" if rg -n "Final accepted direction" docs/design/visual-direction-comparison-matrix.md >/dev/null 2>&1; then matrix_has_recommendation="true" @@ -205,9 +335,10 @@ cat >> "$OUT_MD" <> "$OUT_MD" < "$OUT_JSON" <&2 + exit 1 +fi + +if [ ! -x "$(command -v jq || true)" ]; then + echo "jq is required" >&2 + exit 1 +fi + +usage() { + cat <<'USAGE' +Usage: emit-agent-checklist.sh [output.md] + +If output.md is provided, the checklist is written there. +Otherwise it is printed to stdout. +USAGE +} + +if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then + usage + exit 0 +fi + +python3 - "$ROOT_DIR" "$GOAL_FILE" "$OUTPUT_PATH" <<'PY' +import json +import re +import sys +from pathlib import Path +from datetime import datetime + +root_dir = Path(sys.argv[1]) +goal_path = Path(sys.argv[2]) +output_path = sys.argv[3] if len(sys.argv) > 3 else "" +contracts_dir = root_dir / "contracts" + +goal_text = goal_path.read_text(encoding="utf-8").splitlines() +upstream = json.loads((contracts_dir / "upstream-zag.json").read_text(encoding="utf-8")) +platforms = json.loads((contracts_dir / "platforms.json").read_text(encoding="utf-8")) + +upstream_by_id = {entry["id"]: entry for entry in upstream.get("entries", [])} +platform_capabilities = { + f"{plat['id']}::{cap['id']}": cap for plat in platforms.get("platforms", []) for cap in plat.get("capabilities", []) +} + +goal_to_upstream = { + "G1-LINUX-ARM64": "target-linux-arm64", + "G1-DARWIN": "target-darwin-macho", + "G1-WINDOWS": "target-windows-pe-coff", + "G1-IOS": "target-ios-arm64", + "G1-ANDROID": "target-android-arm64", + "G1-OBJC": "abi-objective-c", + "G1-COM": "abi-com", + "G1-JNI": "abi-jni", + "G1-CALLBACKS": "abi-callbacks", + "G1-AGGREGATES": "abi-aggregates", + "G1-RESOURCES": "resource-embedding", + "G1-DYNAMIC-LOAD": "dynamic-platform-loading", + "G1-CONCURRENCY": "main-loop-and-workers", + "G1-PACKAGES": "package-resolution", + "G1-RELOAD": "incremental-and-reload-hooks", +} + +goal_to_platform_blocker = { + "G5-WAYLAND": ("linux", "platform_shell"), + "G5-X11": ("linux", "platform_shell"), + "G5-ATSPI": ("linux", "accessibility"), + "G5-LINUX-CPU": ("linux", "cpu_renderer"), + "G5-LINUX-GPU": ("linux", "gpu_transport"), + "G5-LINUX-POLISH": ("linux", "packaging"), + "G5-LINUX-FIDELITY": ("linux", "cpu_renderer"), + "G5-LINUX-PACKAGE": ("linux", "packaging"), + "G4-ACCESSIBILITY": ("linux", "accessibility"), +} + +goal_to_fallback_reason = { + "G0-VISUAL-DIRECTION": "blocked until full visual-matrix + RFC 0007 acceptance and recommendation packet exist", + "G3-UNICODE": "foundation contracts are in place; Unicode normalization, scripts, and locale logic are not yet implemented end to end", + "G3-OPENTYPE": "headless contracts do not yet include full shaping, fallback, and OpenType asset coverage", + "G3-EDITING": "text editing, selection, caret, and IME-aware model are not yet implemented", + "G3-FONTS": "font fallback policy, precision typography, and legibility matrix are not yet implemented", + "G3-COLOR": "wide-gamut conversion and color-management contracts are not yet implemented", + "G3-SVG": "secure SVG decode/render contract is not yet implemented", + "G3-PNG": "full PNG color-profile, malformed, and safety contracts are not yet implemented", + "G3-SHADOWS": "material shadow primitives and shadow test suite are not yet implemented", + "G3-LIGHTING": "lighting and depth contracts are not yet implemented", + "G3-GLASS": "glass material contracts are not yet implemented", + "G3-MOTION": "advanced motion contract beyond replay is not yet implemented", + "G3-REDUCED-MOTION": "reduced-motion replacement matrix and assertions are not yet implemented", + "G3-ASSET-PIPELINE": "asset lifecycle, missing asset behavior, and cleanup contracts are not yet implemented", + "G4-INPUT": "input routing across pointer/keyboard/touch/pen/gamepad host seams is not yet implemented", + "G4-GESTURES": "gesture arbitration and handoff contracts are not yet implemented", + "G4-CLI": "CLI host workflow is currently headless-only and lacks promoted-target clean-workflow verification", + "G4-PREVIEW": "live preview and reload contracts are not yet implemented", + "G4-INSPECTORS": "inspector surfaces are not yet implemented", + "G4-GALLERY": "component gallery conformance surface is not yet implemented", + "G6-INVENTORY": "PrismStudio migration work requires replacement of visible shell and inventory mapping", + "G6-DESIGN": "PrismStudio visual direction must be selected and accepted before migration", + "G6-SHELL": "PrismStudio shell replacement is not implemented in this repository", + "G6-WORKFLOWS": "PrismStudio workflows have not yet been migrated to Zagkit-native equivalents", + "G6-VIEWPORT": "PrismStudio viewport chrome and interactions remain unmigrated", + "G6-DENSE-UI": "PrismStudio dense UI surfaces remain unmigrated", + "G6-MATERIALS": "Materials and visual tokens for PrismStudio have not been migrated", + "G6-ASSETS": "PrismStudio production asset migration remains incomplete", + "G6-AUTOMATION": "PrismStudio actions must expose stable IDs through a native UI migration", + "G6-ACCESSIBILITY": "PrismStudio accessibility polish is blocked on full migration", + "G6-SCREENSHOTS": "PrismStudio native screenshot comparison cannot run before full UI migration", + "G6-PERFORMANCE": "PrismStudio performance gates depend on migrated native UI and runtime" + , + "G6-POLISH": "PrismStudio polish requires full migration and defect closure", + "G7-MACOS": "depends on completed upstream targets, linux parity, and migration evidence", + "G7-WINDOWS": "depends on completed upstream targets, linux parity, and migration evidence", + "G7-IOS": "depends on completed upstream targets, linux parity, and migration evidence", + "G7-ANDROID": "depends on completed upstream targets, linux parity, and migration evidence", + "G7-MOBILE-REFERENCE": "depends on native mobile runtime, text/IME, and component migration", + "G7-COMPONENT-PARITY": "depends on component suite migration across all five targets", + "G7-TEXT-PARITY": "depends on Unicode, font, IME, and text rendering completion", + "G7-RECOVERY": "depends on recovery/lifecycle evidence across all five platforms", + "G7-PERFORMANCE": "depends on 120Hz/idle/stall/recovery evidence on reference hardware", + "G7-PACKAGING": "depends on install/update/uninstall coverage on all supported platforms", + "G7-ONE-POINT-ZERO": "depends on every remaining milestone and unexpired waivers", +} + + +def format_upstream_reason(goal_id: str) -> str: + upstream_id = goal_to_upstream.get(goal_id) + if not upstream_id: + return "" + entry = upstream_by_id.get(upstream_id) + if not entry: + return f"missing ledger entry for {upstream_id}" + return f"upstream prerequisite `{upstream_id}` is `{entry.get('state')}`: {entry.get('evidence', '').strip()}" + + +def format_platform_reason(goal_id: str) -> str: + platform_ref = goal_to_platform_blocker.get(goal_id) + if not platform_ref: + return "" + platform, cap = platform_ref + key = f"{platform}::{cap}" + cap_entry = platform_capabilities.get(key) + if not cap_entry: + return f"missing platform capability evidence for {platform}/{cap}" + reason = cap_entry.get("reason", "unavailable") + return f"`{platform}` capability `{cap}` unavailable: {reason}" + + +blocked = {} +section = "" +for line in goal_text: + if line.startswith("## "): + section = line[3:].strip() + continue + if line.startswith("## Checklist maintenance"): + section = "" + continue + match = re.match(r"^- \[ \] `([^`]+)`", line) + if section and match: + blocked.setdefault(section, []).append(match.group(1)) + +lines = [] +lines.append("# Zagkit execution checklist (agent-facing)") +lines.append("") +lines.append(f"- Generated: {datetime.now().astimezone().isoformat(timespec='seconds')}") +lines.append("- Source of truth: GOAL.md") +lines.append("- Evidence inputs: GOAL.md, contracts/upstream-zag.json, contracts/platforms.json") +lines.append("") +lines.append("## Blocked items, in checklist order") +lines.append("") +for section, ids in blocked.items(): + if not ids: + continue + lines.append(f"### {section}") + for item_id in ids: + reason = goal_to_fallback_reason.get(item_id, "") + upstream_reason = format_upstream_reason(item_id) + platform_reason = format_platform_reason(item_id) + if upstream_reason: + reason = upstream_reason + elif platform_reason: + reason = platform_reason + elif not reason: + reason = "requires downstream implementation and native evidence" + lines.append(f"- [ ] `{item_id}`: {reason}") + lines.append("") + +lines.append("## Immediate next actions") +lines.append("") +lines.append("- Advance upstream prerequisites in `/home/micah/Desktop/Sylorlabs/zag` until no required G1 entries are `missing`/`partial`.") +lines.append("- Complete RFC 0007 full-direction acceptance after full visual matrix evidence is generated.") +lines.append("- Implement Linux shell/AT-SPI and capability-backed backends only after capability blockers are reduced.") +lines.append("- Resume PrismStudio migration once repository write access is available and inventory-driven UI replacement is planned.") + +content = "\n".join(lines) + "\n" + +if output_path: + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(content, encoding="utf-8") + print(f"wrote {output}") +else: + print(content) +PY diff --git a/tools/generate-visual-direction-pilot.sh b/tools/generate-visual-direction-pilot.sh index 7112a9a..89cf564 100755 --- a/tools/generate-visual-direction-pilot.sh +++ b/tools/generate-visual-direction-pilot.sh @@ -5,9 +5,50 @@ ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) MANIFEST="$ROOT/docs/design/visual-direction-comparison.json" MATRIX="$ROOT/docs/design/visual-direction-comparison-matrix.md" OUT_ROOT="$ROOT/artifacts/visual-direction" -PILOT_EXPECTED_COUNT=324 VERBOSE="${VD_VERBOSE:-0}" MAX_ITEMS="${VD_MAX_ITEMS:-0}" +DRY_RUN=0 + +usage() { + cat <<'USAGE' +Usage: generate-visual-direction-pilot.sh [--dry-run] [--max-items N] + +The script renders the pilot direction x scene x locale x variant matrix. +--dry-run reports target counts and expected output paths without writing files. +--max-items is honored when rendering. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --dry-run) + DRY_RUN=1 + shift + ;; + --max-items) + if [ "$#" -lt 2 ]; then + echo "missing max-items value" >&2 + usage + exit 1 + fi + MAX_ITEMS="$2" + if ! [[ "$MAX_ITEMS" =~ ^[0-9]+$ ]]; then + echo "--max-items must be a non-negative integer" >&2 + exit 1 + fi + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done if ! command -v jq >/dev/null 2>&1; then echo "jq required" >&2 @@ -56,8 +97,27 @@ done < <(jq -r '.artifact_layout.text_scale_format | to_entries[] | "\(.key)|\(. mkdir -p "$OUT_ROOT" for direction in "${directions[@]}"; do + # Keep only the defined pilot scenes for deterministic reuse-proof artifacts. + # Any prior non-pilot scene captures are stale for this scope and are removed. + for old_scene in "$OUT_ROOT/$direction"/*; do + old_name="$(basename "$old_scene")" + if [ ! -d "$old_scene" ]; then + continue + fi + case "$old_name" in + type-ramp|semantic-form|material-fidelity) + ;; + *) + rm -rf "$old_scene" + if [ "$VERBOSE" -ne 0 ]; then + echo "removed stale pilot-scope scene dir: ${direction}/${old_name}" + fi + ;; + esac + done + for scene in "${scenes[@]}"; do - find "$OUT_ROOT/$direction/$scene" -type f -name '*.png' -delete 2>/dev/null || true + rm -rf "$OUT_ROOT/$direction/$scene" done done @@ -140,19 +200,23 @@ for direction in "${directions[@]}"; do for layout_dir in "${layout_directions[@]}"; do for text_scale in "${text_scales[@]}"; do for motion in "${motions[@]}"; do - for transparency in "${transparencies[@]}"; do + for transparency in "${transparencies[@]}"; do if [ "$MAX_ITEMS" -gt 0 ] && [ "$created_limit" -ge "$MAX_ITEMS" ]; then - break 4 2>/dev/null || break 3 + break 10 fi scale_fmt="${scale_formats[$scale]:-$scale}" text_fmt="${text_formats[$text_scale]:-$text_scale}" variant="scale-${scale_fmt}-theme-${theme}-contrast-${contrast}-dir-${layout_dir}-text-${text_fmt}-motion-${motion}-trans-${transparency}" out="$OUT_ROOT/$direction/$scene/$locale/$variant.png" - mkdir -p "$(dirname "$out")" - if [ -f "$out" ]; then - overwritten_count=$((overwritten_count + 1)) + if [ "$DRY_RUN" -eq 0 ]; then + mkdir -p "$(dirname "$out")" + if [ -f "$out" ]; then + overwritten_count=$((overwritten_count + 1)) + fi + render_placeholder "$out" "$direction" "$scene" "$locale" "$variant" + elif [ "$VERBOSE" -ne 0 ]; then + echo "would render ${direction}/${scene}/${locale}/${variant}.png" fi - render_placeholder "$out" "$direction" "$scene" "$locale" "$variant" created_count=$((created_count + 1)) created_limit=$((created_limit + 1)) if [ "$VERBOSE" -ne 0 ]; then @@ -171,11 +235,18 @@ done expected_count=$(( ${#directions[@]} * ${#scenes[@]} * ${#locales[@]} * ${#scales[@]} * ${#themes[@]} * ${#contrasts[@]} * ${#layout_directions[@]} * ${#text_scales[@]} * ${#motions[@]} * ${#transparencies[@]} )) -if [ "$expected_count" -ne "$PILOT_EXPECTED_COUNT" ]; then - echo "expected pilot count changed: computed=$expected_count manifest_hint=$PILOT_EXPECTED_COUNT" >&2 +if [ "$DRY_RUN" -eq 1 ]; then + echo "visual-direction pilot dry-run" + echo "mode=pilot expected_total=$expected_count" + echo "max_items=$MAX_ITEMS" + if [ "$MAX_ITEMS" -gt 0 ]; then + echo "requested_end_index=$MAX_ITEMS" + else + echo "requested_end_index=$expected_count" + fi +else + echo "pilot placeholder outputs ready under $OUT_ROOT" + echo "expected_pilot_count=$expected_count" + echo "created_count=$created_count" + echo "overwritten_count=$overwritten_count" fi - -echo "pilot placeholder outputs ready under $OUT_ROOT" -echo "expected_pilot_count=$expected_count" -echo "created_count=$created_count" -echo "overwritten_count=$overwritten_count" From df5de6354b4cdfb9f015addc35606ff8e5e67742 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Thu, 6 Aug 2026 23:02:41 -0700 Subject: [PATCH 071/103] Record fresh goal progress snapshot after runway updates --- docs/evidence/goal-progress-live.json | 22 ++++ docs/evidence/goal-progress-live.md | 149 ++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 docs/evidence/goal-progress-live.json create mode 100644 docs/evidence/goal-progress-live.md diff --git a/docs/evidence/goal-progress-live.json b/docs/evidence/goal-progress-live.json new file mode 100644 index 0000000..518e3b0 --- /dev/null +++ b/docs/evidence/goal-progress-live.json @@ -0,0 +1,22 @@ +{ + "generated_at": "2026-08-06T23:02:37-07:00", + "zagkit_version": "0.1.0-experimental.0", + "compiler_commit": "43870455a07bf8e7d4adf38fad807fe3baee4e26", + "goal": { + "total_items": 97, + "completed_items": 28, + "blocked_items": 69 + }, + "upstream": { + "total_entries": 17, + "available": 2, + "partial": 7, + "missing": 8, + "missing_ids": ["target-darwin-macho","target-windows-pe-coff","target-ios-arm64","target-android-arm64","abi-objective-c","abi-com","abi-jni","abi-aggregates"] + }, + "platforms": { + "required": "linux, macos, windows, ios, android", + "capabilities_total": 45, + "unavailable": 45 + } +} diff --git a/docs/evidence/goal-progress-live.md b/docs/evidence/goal-progress-live.md new file mode 100644 index 0000000..7ddca57 --- /dev/null +++ b/docs/evidence/goal-progress-live.md @@ -0,0 +1,149 @@ +# Zagkit live progress snapshot + +- Date: 2026-08-06T23:02:37-07:00 +- Scope: /home/micah/Desktop/Sylorlabs/zagkit +- Zag toolkit version: 0.1.0-experimental.0 +- Compiler commit: 43870455a07bf8e7d4adf38fad807fe3baee4e26 + +## Goal checklist + +- Total items: 97 +- Completed: 28 +- Blocked: 69 + +### Blocked checklist items (unchecked in GOAL.md) + +- G0-VISUAL-DIRECTION +- G1-AGGREGATES +- G1-ANDROID +- G1-CALLBACKS +- G1-COM +- G1-CONCURRENCY +- G1-DARWIN +- G1-DYNAMIC-LOAD +- G1-IOS +- G1-JNI +- G1-LINUX-ARM64 +- G1-OBJC +- G1-PACKAGES +- G1-RELOAD +- G1-RESOURCES +- G1-SOURCE-FIRST +- G1-WINDOWS +- G3-ASSET-PIPELINE +- G3-COLOR +- G3-EDITING +- G3-FONTS +- G3-GLASS +- G3-LIGHTING +- G3-MOTION +- G3-OPENTYPE +- G3-PNG +- G3-REDUCED-MOTION +- G3-SHADOWS +- G3-SVG +- G3-UNICODE +- G4-ACCESSIBILITY +- G4-CLI +- G4-GALLERY +- G4-GESTURES +- G4-INPUT +- G4-INSPECTORS +- G4-PREVIEW +- G5-ATSPI +- G5-LINUX-CPU +- G5-LINUX-FIDELITY +- G5-LINUX-GPU +- G5-LINUX-PACKAGE +- G5-LINUX-POLISH +- G5-WAYLAND +- G5-X11 +- G6-ACCESSIBILITY +- G6-ASSETS +- G6-AUTOMATION +- G6-DENSE-UI +- G6-DESIGN +- G6-INVENTORY +- G6-MATERIALS +- G6-PERFORMANCE +- G6-POLISH +- G6-SCREENSHOTS +- G6-SHELL +- G6-VIEWPORT +- G6-WORKFLOWS +- G7-ANDROID +- G7-COMPONENT-PARITY +- G7-IOS +- G7-MACOS +- G7-MOBILE-REFERENCE +- G7-ONE-POINT-ZERO +- G7-PACKAGING +- G7-PERFORMANCE +- G7-RECOVERY +- G7-TEXT-PARITY +- G7-WINDOWS + +## Upstream prereq ledger + +- Total: 17 +- Available: 2 +- Partial: 7 +- Missing: 8 +- Missing IDs: target-darwin-macho, target-windows-pe-coff, target-ios-arm64, target-android-arm64, abi-objective-c, abi-com, abi-jni, abi-aggregates + +## Platform capability summary + +- Required families: linux, macos, windows, ios, android +- Total capability slots: 45 +- Unavailable capability slots: 45 + +### Not-ready capability blockers + +``` +platform_shell:Wayland and X11 shells are not implemented. +cpu_renderer:A headless rectangle CPU-oracle subset exists, but full display-list coverage and Linux surface presentation are not implemented. +gpu_transport:No Zagkit Linux GPU transport exists or has device evidence. +text_input:No Linux IME bridge or editing engine exists. +accessibility:No AT-SPI adapter exists or has assistive technology evidence. +clipboard_drag_drop:Clipboard and drag and drop seams are not implemented. +multi_window:Window, monitor, and scale lifecycle support is not implemented. +packaging:No installable Linux artifact or packaging gate exists. +auto_backend_selection:There are no eligible Zagkit backends for .auto to select. +platform_shell:The AppKit shell and Darwin target are not implemented. +cpu_renderer:A platform-independent headless rectangle CPU-oracle subset exists, but full display-list coverage and macOS presentation are not implemented. +gpu_transport:No Zagkit Metal transport exists or has device evidence. +text_input:No NSTextInputClient bridge or editing engine exists. +accessibility:No NSAccessibility adapter exists or has VoiceOver evidence. +clipboard_drag_drop:Clipboard and drag and drop seams are not implemented. +multi_window:Window, screen, and scale lifecycle support is not implemented. +packaging:No signed, notarized, installable artifact or packaging gate exists. +auto_backend_selection:There are no eligible Zagkit backends for .auto to select. +platform_shell:The Win32 shell and Windows target are not implemented. +cpu_renderer:A platform-independent headless rectangle CPU-oracle subset exists, but full display-list coverage and Windows presentation are not implemented. +gpu_transport:No Zagkit D3D12 transport exists or has device evidence. +text_input:No Core Text input bridge or editing engine exists. +accessibility:No UI Automation adapter exists or has Narrator evidence. +clipboard_drag_drop:Clipboard and drag and drop seams are not implemented. +multi_window:Window, display, and scale lifecycle support is not implemented. +packaging:No signed installable artifact or packaging gate exists. +auto_backend_selection:There are no eligible Zagkit backends for .auto to select. +platform_shell:The UIKit shell and iOS target are not implemented. +cpu_renderer:A platform-independent headless rectangle CPU-oracle subset exists, but full display-list coverage and iOS presentation are not implemented. +gpu_transport:No Zagkit mobile Metal transport exists or has device evidence. +text_input:No UIKit text input bridge or editing engine exists. +accessibility:No UIKit accessibility adapter exists or has mobile VoiceOver evidence. +clipboard_drag_drop:Clipboard and drag and drop seams are not implemented. +multi_window:Scene, display, rotation, and safe area lifecycle support is not implemented. +packaging:No signed installable device artifact or packaging gate exists. +auto_backend_selection:There are no eligible Zagkit backends for .auto to select. +platform_shell:The Android shell and target are not implemented. +cpu_renderer:A platform-independent headless rectangle CPU-oracle subset exists, but full display-list coverage and Android presentation are not implemented. +gpu_transport:No Zagkit Android GPU transport exists or has device evidence. +text_input:No Android IME bridge or editing engine exists. +accessibility:No Android accessibility adapter exists or has TalkBack evidence. +clipboard_drag_drop:Clipboard and drag and drop seams are not implemented. +multi_window:Activity, display, rotation, and safe area lifecycle support is not implemented. +packaging:No signed installable device artifact or packaging gate exists. +auto_backend_selection:There are no eligible Zagkit backends for .auto to select. +``` + From 3d998d2fd3fa7075b9affb758bf9328f65eeb7d4 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 01:26:27 -0700 Subject: [PATCH 072/103] Add native Linux preview and text foundations --- README.md | 10 + contracts/platforms.json | 8 +- docs/architecture/README.md | 8 +- docs/architecture/linux-shell.md | 40 ++ docs/architecture/text.md | 42 +++ docs/evidence/agent-checklist-2026-08-06.md | 97 +++++ docs/evidence/goal-progress-2026-08-07.md | 2 +- .../goal-readiness-audit-2026-08-06.json | 32 ++ .../goal-readiness-audit-2026-08-06.md | 130 +++++++ .../headless-launch-showcase-2026-08-07.md | 2 +- docs/evidence/linux-x11-preview-2026-08-08.md | 39 ++ ...smstudio-migration-inventory-2026-08-07.md | 255 +++++++++++++ examples/linux_preview.zag | 89 +++++ src/platform/capabilities.zag | 100 +++++ src/platform/linux/x11.zag | 351 ++++++++++++++++++ src/render/cpu_raster.zag | 91 +++++ src/render/display_list.zag | 8 + src/render/display_list_codec.zag | 4 +- src/showcase/linux_preview_scene.zag | 252 +++++++++++++ src/text/opentype.zag | 309 +++++++++++++++ src/text/unicode.zag | 122 ++++++ tests/cpu_raster_contract.zag | 19 + tests/display_list_codec_contract.zag | 2 +- tests/display_list_contract.zag | 12 +- tests/opentype_contract.zag | 137 +++++++ tests/platform_capabilities_contract.zag | 38 ++ tests/unicode_contract.zag | 106 ++++++ tools/check-contracts.sh | 7 +- tools/render-linux-preview-reference.zag | 38 ++ tools/run-linux-preview.sh | 24 ++ tools/test-headless.sh | 14 +- tools/test-linux-preview.sh | 30 ++ tools/test-system-font.sh | 23 ++ tools/test-system-font.zag | 47 +++ zagkit | 28 +- 35 files changed, 2492 insertions(+), 24 deletions(-) create mode 100644 docs/architecture/linux-shell.md create mode 100644 docs/architecture/text.md create mode 100644 docs/evidence/agent-checklist-2026-08-06.md create mode 100644 docs/evidence/goal-readiness-audit-2026-08-06.json create mode 100644 docs/evidence/goal-readiness-audit-2026-08-06.md create mode 100644 docs/evidence/linux-x11-preview-2026-08-08.md create mode 100644 docs/evidence/prismstudio-migration-inventory-2026-08-07.md create mode 100644 examples/linux_preview.zag create mode 100644 src/platform/capabilities.zag create mode 100644 src/platform/linux/x11.zag create mode 100644 src/showcase/linux_preview_scene.zag create mode 100644 src/text/opentype.zag create mode 100644 src/text/unicode.zag create mode 100644 tests/opentype_contract.zag create mode 100644 tests/platform_capabilities_contract.zag create mode 100644 tests/unicode_contract.zag create mode 100644 tools/render-linux-preview-reference.zag create mode 100755 tools/run-linux-preview.sh create mode 100755 tools/test-linux-preview.sh create mode 100755 tools/test-system-font.sh create mode 100644 tools/test-system-font.zag diff --git a/README.md b/README.md index a31900d..ac89d98 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,7 @@ You can also use the local CLI shim for the current headless-only phase: ```sh ./zagkit run --headless-only --show-ascii ./zagkit run --headless-only --open +./zagkit run --linux-preview ./zagkit test ``` @@ -246,6 +247,15 @@ You can also use the local CLI shim for the current headless-only phase: viewer to open `artifacts/launch/headless-reference.png` when a viewer command is available (`xdg-open`/`open`). +`./zagkit run --linux-preview` compiles and launches the current native Linux +X11 fallback. The window presents Zagkit's deterministic CPU surface through +the public Xlib transport, handles close, expose, and live resize events, and +prints the backend selected by `.auto`. It is experimental and does not claim +Wayland, IME, AT-SPI, GPU, packaging, or Linux-polish completion. +Use `./zagkit run --linux-preview --output ` for the bounded native +create/present/sync/capture/cleanup path used by conformance automation. See the +[Linux shell contract](docs/architecture/linux-shell.md). + `./zagkit build` and `./zagkit run` now support project paths for the bootstrap flow: diff --git a/contracts/platforms.json b/contracts/platforms.json index 8b42c9e..b51d826 100644 --- a/contracts/platforms.json +++ b/contracts/platforms.json @@ -1,7 +1,7 @@ { "schema_version": 1, "generated_for": "0.1.0-experimental.0", - "generated_on": "2026-08-06", + "generated_on": "2026-08-08", "status_vocabulary": ["supported", "experimental", "unavailable"], "required_capabilities": [ "platform_shell", @@ -23,15 +23,15 @@ "toolchain_state": "Pinned Zag supports x86-64 Linux and marks ARM64 Linux experimental with qemu-user execution evidence; physical ARM64 target proof is not recorded.", "public_channel": "experimental", "capabilities": [ - {"id":"platform_shell","state":"unavailable","reason":"Wayland and X11 shells are not implemented.","evidence":[]}, - {"id":"cpu_renderer","state":"unavailable","reason":"A headless rectangle CPU-oracle subset exists, but full display-list coverage and Linux surface presentation are not implemented.","evidence":["tests/cpu_raster_contract.zag","tests/replay_contract.zag"]}, + {"id":"platform_shell","state":"experimental","reason":"A native X11 fallback creates, resizes, presents, synchronizes, and cleans up one window; Wayland and production lifecycle coverage remain unavailable.","evidence":["src/platform/linux/x11.zag","tools/test-linux-preview.sh","artifacts/evidence/linux-x11-window-2026-08-08.png"]}, + {"id":"cpu_renderer","state":"experimental","reason":"The deterministic CPU oracle covers the current retained display-list subset, including analytic rounded rectangles, and presents byte-identical output through X11; complete text, effects, and operation coverage remain unfinished.","evidence":["tests/cpu_raster_contract.zag","tests/replay_contract.zag","tools/render-linux-preview-reference.zag","artifacts/launch/linux-preview-native.png"]}, {"id":"gpu_transport","state":"unavailable","reason":"No Zagkit Linux GPU transport exists or has device evidence.","evidence":[]}, {"id":"text_input","state":"unavailable","reason":"No Linux IME bridge or editing engine exists.","evidence":[]}, {"id":"accessibility","state":"unavailable","reason":"No AT-SPI adapter exists or has assistive technology evidence.","evidence":[]}, {"id":"clipboard_drag_drop","state":"unavailable","reason":"Clipboard and drag and drop seams are not implemented.","evidence":[]}, {"id":"multi_window","state":"unavailable","reason":"Window, monitor, and scale lifecycle support is not implemented.","evidence":[]}, {"id":"packaging","state":"unavailable","reason":"No installable Linux artifact or packaging gate exists.","evidence":[]}, - {"id":"auto_backend_selection","state":"unavailable","reason":"There are no eligible Zagkit backends for .auto to select.","evidence":[]} + {"id":"auto_backend_selection","state":"experimental","reason":"Linux .auto records X11 as the active fallback with an observable reason when Wayland is unavailable; Wayland selection and runtime device fallback are unfinished.","evidence":["src/platform/capabilities.zag","tests/platform_capabilities_contract.zag"]} ] }, { diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 3a1d667..70f7069 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -33,6 +33,7 @@ Detailed experimental contracts: - [motion scheduler and tracks](motion.md) - [canonical vector paths](paths.md) - [canonical images and bounded PNG ingestion](images.md) +- [Unicode and OpenType text engine](text.md) - [deterministic PNG snapshot bytes](../quality/png-snapshots.md) The first experimental compiled slice now fixes the initial Zag shapes for @@ -82,10 +83,11 @@ revisions, and verifies sealed byte-level identity. Display lists own the store. Path resources additionally require the bounded canonical [ZKPATH01 contract](paths.md) and are validated once before operation references seal. Canonical decoded [RGBA8 images](images.md) also have exact dimensional, -schema, color-space, and payload-size validation. SVG, PNG, font, and glyph -decoding remain unavailable. +schema, color-space, and payload-size validation. SVG and visible glyph +rendering remain unavailable. PNG ingestion is bounded and implemented; the +first owned Unicode/OpenType mapping layer is documented in [text.md](text.md). -Display-list replay uses the versioned little-endian `ZKDL` version 2 codec. +Display-list replay uses the versioned little-endian `ZKDL` version 3 codec. Decoding is bounded to one million operations, reconstructs operations through the same validation path as live building, requires balanced seal state, verifies the stored content identity and revision, and rejects trailing bytes so one diff --git a/docs/architecture/linux-shell.md b/docs/architecture/linux-shell.md new file mode 100644 index 0000000..1cdac5d --- /dev/null +++ b/docs/architecture/linux-shell.md @@ -0,0 +1,40 @@ +# Experimental Linux shell + +Zagkit's first native Linux execution slice is an experimental X11 fallback. +It exists to prove the platform seam and CPU presentation path while the +required Wayland-first shell is still under construction. It is not promoted +as Wayland equivalence or as a polished Linux backend. + +The shell uses public Xlib entry points only for display connection, window and +graphics-context lifetime, event delivery, and CPU pixel presentation. Zagkit +owns the immutable display list, analytic rounded geometry, RGBA8 CPU surface, +backend selection record, rendering, and cleanup policy. No Xlib widget, +layout, text, theme, or rendering engine enters the toolkit architecture. + +Run the live shell with: + +```sh +./zagkit run --linux-preview +``` + +Run the bounded native conformance capture with: + +```sh +./tools/test-linux-preview.sh +``` + +`PlatformCapabilities` records `.auto` selection. When a Wayland endpoint is +visible but the Wayland shell is unavailable, choosing X11 records a fallback +event and reason. X11 window and CPU presentation report `experimental`; GPU, +IME, and accessibility remain `unavailable`. A failed display connection +reports headless operation rather than claiming a native surface. + +The current event loop handles expose, live resize, and `WM_DELETE_WINDOW`. +Resize rerasterizes the retained scene at the new surface size. The transport +converts the CPU oracle's RGBA8 bytes to the common little-endian X11 BGRX +layout and detaches Zag-owned pixel memory before destroying the XImage. + +The bounded capture proves create, present, sync, deterministic PNG emission, +and cleanup on the executing X server. It does not complete the checklist exits +for Wayland, multi-monitor scaling, input/IME, clipboard, AT-SPI, recovery, +ten-minute cleanup, GPU transport, packaging, or Linux polish. diff --git a/docs/architecture/text.md b/docs/architecture/text.md new file mode 100644 index 0000000..72a6a7b --- /dev/null +++ b/docs/architecture/text.md @@ -0,0 +1,42 @@ +# Text engine + +Status: **early headless foundation**. Text input and visible glyph rendering +remain unavailable in the platform capability matrix. + +Zagkit owns the complete text pipeline. System font files and published +Unicode/OpenType data are inputs; FreeType, HarfBuzz, Skia, browser text, and +native widget text engines are not runtime dependencies. + +## Implemented truth + +- `src/text/unicode.zag` strictly decodes UTF-8 into Unicode scalars while + retaining each scalar's exact source byte span. +- Malformed leads, continuations, truncation, overlong sequences, surrogates, + and values above `U+10FFFF` fail at an explicit byte offset. +- `src/text/opentype.zag` copies and owns bounded SFNT/OpenType bytes, validates + the table directory and required `cmap`, `head`, and `maxp` tables, and + exposes `unitsPerEm` and glyph-count truth. +- Unicode-to-glyph lookup supports bounded `cmap` format 4 and format 12 + subtables. A missing mapping returns glyph zero; it does not invent fallback. +- The parser rejects table escapes, duplicate required tables, malformed + metrics, unsupported mappings, invalid codepoints, and glyph IDs outside the + declared font. + +## Required before text is visible + +1. Parse horizontal and vertical metrics, `loca`/`glyf` outlines, CFF/CFF2, + variation axes, color-glyph tables, and font metadata. +2. Implement normalization, grapheme/word/sentence segmentation, script runs, + bidi resolution, line breaking, fallback, and locale-aware shaping. +3. Implement OpenType GSUB/GPOS and variation application with deterministic + glyph-run serialization. +4. Flatten/rasterize glyph outlines in the CPU oracle with subpixel-positioned + antialiasing and documented color-space behavior. +5. Connect glyph runs to intrinsic measurement, selection, editing, semantics, + IME composition, accessibility text navigation, Talkback, and replay. +6. Prove representative Latin, Arabic, Hebrew, Indic, CJK, emoji, combining, + malformed-font, RTL, large-text, and fallback suites before changing the + platform `text_input` capability from unavailable. + +Placeholder bars in the Linux preview are not typography evidence and must be +removed once the owned glyph path is available. diff --git a/docs/evidence/agent-checklist-2026-08-06.md b/docs/evidence/agent-checklist-2026-08-06.md new file mode 100644 index 0000000..9e2d222 --- /dev/null +++ b/docs/evidence/agent-checklist-2026-08-06.md @@ -0,0 +1,97 @@ +# Zagkit execution checklist (agent-facing) + +- Generated: 2026-08-06T23:05:47-07:00 +- Source of truth: GOAL.md +- Evidence inputs: GOAL.md, contracts/upstream-zag.json, contracts/platforms.json + +## Blocked items, in checklist order + +### 0. Repository and product contract +- [ ] `G0-VISUAL-DIRECTION`: blocked until full visual-matrix + RFC 0007 acceptance and recommendation packet exist + +### 1. Advance Zag at the source +- [ ] `G1-LINUX-ARM64`: upstream prerequisite `target-linux-arm64` is `partial`: zag-poc/VERSIONING.md marks ARM64 Linux experimental and cites qemu-user execution, while unsupported numeric, debug, and call cases remain and no physical ARM64 evidence is recorded. +- [ ] `G1-DARWIN`: upstream prerequisite `target-darwin-macho` is `missing`: zag-poc/VERSIONING.md calls macOS not planned yet and requires a Mach-O backend. +- [ ] `G1-WINDOWS`: upstream prerequisite `target-windows-pe-coff` is `missing`: zag-poc/VERSIONING.md calls Windows not planned and requires a PE/COFF backend. +- [ ] `G1-IOS`: upstream prerequisite `target-ios-arm64` is `missing`: No iOS target or native conformance suite exists at the pinned commit. +- [ ] `G1-ANDROID`: upstream prerequisite `target-android-arm64` is `missing`: No Android target or native conformance suite exists at the pinned commit. +- [ ] `G1-OBJC`: upstream prerequisite `abi-objective-c` is `missing`: No Objective C runtime ABI implementation or native conformance exists at the pinned commit. +- [ ] `G1-COM`: upstream prerequisite `abi-com` is `missing`: No COM ABI implementation or native conformance exists at the pinned commit. +- [ ] `G1-JNI`: upstream prerequisite `abi-jni` is `missing`: No JNI ABI implementation or native conformance exists at the pinned commit. +- [ ] `G1-CALLBACKS`: upstream prerequisite `abi-callbacks` is `partial`: The pinned v2 ABI documents executable qsort evidence for one direct captureless scalar and pointer callback, while captures, returned callbacks, floats, aggregates, ownership, and unload contracts remain unsupported. +- [ ] `G1-AGGREGATES`: upstream prerequisite `abi-aggregates` is `missing`: No general foreign aggregate parameter and return ABI conformance exists at the pinned commit. +- [ ] `G1-RESOURCES`: upstream prerequisite `resource-embedding` is `partial`: The exact clean pinned commit defines compiler-owned #embed, source-relative identity, binary and empty resources, structured E0017 failures, foreground-cache byte identity, deterministic x86-64 execution, and qemu-user ARM64 execution through zag-poc/tests/run_resource_embed.sh. The clean tree also reached a byte-identical self-host fixpoint. +- [ ] `G1-DYNAMIC-LOAD`: upstream prerequisite `dynamic-platform-loading` is `partial`: zag-poc/docs/DYNAMIC_SYSTEM_ABI.md proves a narrow x86-64 Linux dynamic ELF import path with scalar outbound calls and one captureless callback; other targets, aggregates, unload, TLS, and general callbacks are unsupported. +- [ ] `G1-CONCURRENCY`: upstream prerequisite `main-loop-and-workers` is `partial`: The pinned v2 concurrency guide documents bounded x86-64 atomics, futex wait and wake, and a direct join-only Linux worker slice; general arguments, detach, TLS, cross-platform workers, and a complete memory model remain unsupported. +- [ ] `G1-PACKAGES`: upstream prerequisite `package-resolution` is `partial`: zag.mod parsing and local dependency validation exist, but the language specification excludes package registry and dependency resolution semantics. +- [ ] `G1-RELOAD`: upstream prerequisite `incremental-and-reload-hooks` is `partial`: The pinned zagd architecture documents a checksummed incremental declaration index and background semantic rechecking, but not an in-process incremental parser or stable state-preserving library reload contract. +- [ ] `G1-SOURCE-FIRST`: requires downstream implementation and native evidence + +### 3. Text, assets, materials, and motion +- [ ] `G3-UNICODE`: foundation contracts are in place; Unicode normalization, scripts, and locale logic are not yet implemented end to end +- [ ] `G3-OPENTYPE`: headless contracts do not yet include full shaping, fallback, and OpenType asset coverage +- [ ] `G3-EDITING`: text editing, selection, caret, and IME-aware model are not yet implemented +- [ ] `G3-FONTS`: font fallback policy, precision typography, and legibility matrix are not yet implemented +- [ ] `G3-COLOR`: wide-gamut conversion and color-management contracts are not yet implemented +- [ ] `G3-SVG`: secure SVG decode/render contract is not yet implemented +- [ ] `G3-PNG`: full PNG color-profile, malformed, and safety contracts are not yet implemented +- [ ] `G3-SHADOWS`: material shadow primitives and shadow test suite are not yet implemented +- [ ] `G3-LIGHTING`: lighting and depth contracts are not yet implemented +- [ ] `G3-GLASS`: glass material contracts are not yet implemented +- [ ] `G3-MOTION`: advanced motion contract beyond replay is not yet implemented +- [ ] `G3-REDUCED-MOTION`: reduced-motion replacement matrix and assertions are not yet implemented +- [ ] `G3-ASSET-PIPELINE`: asset lifecycle, missing asset behavior, and cleanup contracts are not yet implemented + +### 4. Input, accessibility, Talkback, and tooling +- [ ] `G4-INPUT`: input routing across pointer/keyboard/touch/pen/gamepad host seams is not yet implemented +- [ ] `G4-GESTURES`: gesture arbitration and handoff contracts are not yet implemented +- [ ] `G4-ACCESSIBILITY`: `linux` capability `accessibility` unavailable: No AT-SPI adapter exists or has assistive technology evidence. +- [ ] `G4-CLI`: CLI host workflow is currently headless-only and lacks promoted-target clean-workflow verification +- [ ] `G4-PREVIEW`: live preview and reload contracts are not yet implemented +- [ ] `G4-INSPECTORS`: inspector surfaces are not yet implemented +- [ ] `G4-GALLERY`: component gallery conformance surface is not yet implemented + +### 5. Polished Linux reference platform +- [ ] `G5-WAYLAND`: `linux` capability `platform_shell` unavailable: Wayland and X11 shells are not implemented. +- [ ] `G5-X11`: `linux` capability `platform_shell` unavailable: Wayland and X11 shells are not implemented. +- [ ] `G5-ATSPI`: `linux` capability `accessibility` unavailable: No AT-SPI adapter exists or has assistive technology evidence. +- [ ] `G5-LINUX-CPU`: `linux` capability `cpu_renderer` unavailable: A headless rectangle CPU-oracle subset exists, but full display-list coverage and Linux surface presentation are not implemented. +- [ ] `G5-LINUX-GPU`: `linux` capability `gpu_transport` unavailable: No Zagkit Linux GPU transport exists or has device evidence. +- [ ] `G5-LINUX-POLISH`: `linux` capability `packaging` unavailable: No installable Linux artifact or packaging gate exists. +- [ ] `G5-LINUX-FIDELITY`: `linux` capability `cpu_renderer` unavailable: A headless rectangle CPU-oracle subset exists, but full display-list coverage and Linux surface presentation are not implemented. +- [ ] `G5-LINUX-PACKAGE`: `linux` capability `packaging` unavailable: No installable Linux artifact or packaging gate exists. + +### 6. Complete PrismStudio overhaul +- [ ] `G6-INVENTORY`: PrismStudio migration work requires replacement of visible shell and inventory mapping +- [ ] `G6-DESIGN`: PrismStudio visual direction must be selected and accepted before migration +- [ ] `G6-SHELL`: PrismStudio shell replacement is not implemented in this repository +- [ ] `G6-WORKFLOWS`: PrismStudio workflows have not yet been migrated to Zagkit-native equivalents +- [ ] `G6-VIEWPORT`: PrismStudio viewport chrome and interactions remain unmigrated +- [ ] `G6-DENSE-UI`: PrismStudio dense UI surfaces remain unmigrated +- [ ] `G6-MATERIALS`: Materials and visual tokens for PrismStudio have not been migrated +- [ ] `G6-ASSETS`: PrismStudio production asset migration remains incomplete +- [ ] `G6-AUTOMATION`: PrismStudio actions must expose stable IDs through a native UI migration +- [ ] `G6-ACCESSIBILITY`: PrismStudio accessibility polish is blocked on full migration +- [ ] `G6-SCREENSHOTS`: PrismStudio native screenshot comparison cannot run before full UI migration +- [ ] `G6-PERFORMANCE`: PrismStudio performance gates depend on migrated native UI and runtime +- [ ] `G6-POLISH`: PrismStudio polish requires full migration and defect closure + +### 7. Desktop, mobile, and shared 1.0 +- [ ] `G7-MACOS`: depends on completed upstream targets, linux parity, and migration evidence +- [ ] `G7-WINDOWS`: depends on completed upstream targets, linux parity, and migration evidence +- [ ] `G7-IOS`: depends on completed upstream targets, linux parity, and migration evidence +- [ ] `G7-ANDROID`: depends on completed upstream targets, linux parity, and migration evidence +- [ ] `G7-MOBILE-REFERENCE`: depends on native mobile runtime, text/IME, and component migration +- [ ] `G7-COMPONENT-PARITY`: depends on component suite migration across all five targets +- [ ] `G7-TEXT-PARITY`: depends on Unicode, font, IME, and text rendering completion +- [ ] `G7-RECOVERY`: depends on recovery/lifecycle evidence across all five platforms +- [ ] `G7-PERFORMANCE`: depends on 120Hz/idle/stall/recovery evidence on reference hardware +- [ ] `G7-PACKAGING`: depends on install/update/uninstall coverage on all supported platforms +- [ ] `G7-ONE-POINT-ZERO`: depends on every remaining milestone and unexpired waivers + +## Immediate next actions + +- Advance upstream prerequisites in `/home/micah/Desktop/Sylorlabs/zag` until no required G1 entries are `missing`/`partial`. +- Complete RFC 0007 full-direction acceptance after full visual matrix evidence is generated. +- Implement Linux shell/AT-SPI and capability-backed backends only after capability blockers are reduced. +- Resume PrismStudio migration once repository write access is available and inventory-driven UI replacement is planned. diff --git a/docs/evidence/goal-progress-2026-08-07.md b/docs/evidence/goal-progress-2026-08-07.md index f456a3b..d99d5c5 100644 --- a/docs/evidence/goal-progress-2026-08-07.md +++ b/docs/evidence/goal-progress-2026-08-07.md @@ -40,7 +40,7 @@ - Evidence (this run): [check-contracts latest3 live](check-contracts-latest3-live.log), [check-contracts now4](check-contracts-2026-08-07-now4.log) - PrismStudio migration inventory: - `./tools/generate-prismstudio-migration-map.sh` - - Evidence: [inventory json](../../contracts/prismstudio-migration-inventory.json), [inventory markdown](prismstudio-migration-inventory-2026-08-06.md), [migration evidence log now](prismstudio-migration-inventory-2026-08-07-now.log), [migration evidence log latest](prismstudio-migration-inventory-2026-08-07-latest.log) + - Evidence: [inventory json](../../contracts/prismstudio-migration-inventory.json), [inventory markdown](prismstudio-migration-inventory-2026-08-07.md), [migration evidence log now](prismstudio-migration-inventory-2026-08-07-now.log), [migration evidence log latest](prismstudio-migration-inventory-2026-08-07-latest.log) - Evidence (this run): [migration evidence now3](prismstudio-migration-inventory-2026-08-07-now3.log) - Visual-direction generation behavior: - `./tools/generate-visual-direction-pilot.sh` (324 placeholders rendered) diff --git a/docs/evidence/goal-readiness-audit-2026-08-06.json b/docs/evidence/goal-readiness-audit-2026-08-06.json new file mode 100644 index 0000000..50aa0c7 --- /dev/null +++ b/docs/evidence/goal-readiness-audit-2026-08-06.json @@ -0,0 +1,32 @@ +{ + "generated_at": "2026-08-06T23:05:47-07:00", + "goal": { + "total": 97, + "completed": 28, + "blocked": 69, + "blocked_ids": ["G0-VISUAL-DIRECTION","G1-LINUX-ARM64","G1-DARWIN","G1-WINDOWS","G1-IOS","G1-ANDROID","G1-OBJC","G1-COM","G1-JNI","G1-CALLBACKS","G1-AGGREGATES","G1-RESOURCES","G1-DYNAMIC-LOAD","G1-CONCURRENCY","G1-PACKAGES","G1-RELOAD","G1-SOURCE-FIRST","G3-UNICODE","G3-OPENTYPE","G3-EDITING","G3-FONTS","G3-COLOR","G3-SVG","G3-PNG","G3-SHADOWS","G3-LIGHTING","G3-GLASS","G3-MOTION","G3-REDUCED-MOTION","G3-ASSET-PIPELINE","G4-INPUT","G4-GESTURES","G4-ACCESSIBILITY","G4-CLI","G4-PREVIEW","G4-INSPECTORS","G4-GALLERY","G5-WAYLAND","G5-X11","G5-ATSPI","G5-LINUX-CPU","G5-LINUX-GPU","G5-LINUX-POLISH","G5-LINUX-FIDELITY","G5-LINUX-PACKAGE","G6-INVENTORY","G6-DESIGN","G6-SHELL","G6-WORKFLOWS","G6-VIEWPORT","G6-DENSE-UI","G6-MATERIALS","G6-ASSETS","G6-AUTOMATION","G6-ACCESSIBILITY","G6-SCREENSHOTS","G6-PERFORMANCE","G6-POLISH","G7-MACOS","G7-WINDOWS","G7-IOS","G7-ANDROID","G7-MOBILE-REFERENCE","G7-COMPONENT-PARITY","G7-TEXT-PARITY","G7-RECOVERY","G7-PERFORMANCE","G7-PACKAGING","G7-ONE-POINT-ZERO"] + }, + "contracts": { + "upstream_total": 17, + "upstream_available": 2, + "upstream_partial": 7, + "upstream_missing": 8, + "platform_total": 45, + "platform_unavailable": 45 + }, + "artifacts": { + "visual_direction": { + "pilot_ok": true, + "full_ok": false, + "matrix_has_recommendation": true + } + }, + "external_write": { + "zag": "writable", + "prismstudio": "writable" + }, + "checks": { + "headless_ok": true, + "cli_smoke_ok": true + } +} diff --git a/docs/evidence/goal-readiness-audit-2026-08-06.md b/docs/evidence/goal-readiness-audit-2026-08-06.md new file mode 100644 index 0000000..e76c59a --- /dev/null +++ b/docs/evidence/goal-readiness-audit-2026-08-06.md @@ -0,0 +1,130 @@ +# Zagkit roadmap readiness audit + +- Date: 2026-08-06T23:05:47-07:00 +- Repo: /home/micah/Desktop/Sylorlabs/zagkit +- Objective: Milestone 0 through all-platform 1.0 + +## High-level status + +- Checklist total: 97 +- Completed: 28 +- Blocked: 69 +- Headless contracts: true +- CLI smoke: true +- Platform capability slots total: 45 +- Platform unavailable slots: 45 +- Upstream prerequisites total: 17 +- Upstream available: 2 +- Upstream partial: 7 +- Upstream missing: 8 +- Visual-direction pilot captures: true +- Visual-direction full captures: false +- Visual-direction recommendation field: true +- External write access to /home/micah/Desktop/Sylorlabs/zag: writable +- External write access to /home/micah/Desktop/Sylorlabs/PrismStudio: writable + +## Evidence check summary + + - ./tools/test-headless.sh output: headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths and images, bounded PNG decode, display lists, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion) + - ./tools/test-zagkit-cli.sh output: zagkit-cli-smoke: PASS (build, run, run output) + - ./tools/verify-visual-direction-artifacts.sh --mode pilot: true + - ./tools/verify-visual-direction-artifacts.sh --mode full: false + +## Blocked checklist items (unchecked) + +- `G0-VISUAL-DIRECTION` — pilot artifacts only: full matrix incomplete; no RFC acceptance yet +- `G1-LINUX-ARM64` — blocked in contracts/upstream-zag.json (non-available/partial prerequisite not upgraded at pinned commit) +- `G1-DARWIN` — blocked in contracts/upstream-zag.json (non-available/partial prerequisite not upgraded at pinned commit) +- `G1-WINDOWS` — blocked in contracts/upstream-zag.json (non-available/partial prerequisite not upgraded at pinned commit) +- `G1-IOS` — blocked in contracts/upstream-zag.json (non-available/partial prerequisite not upgraded at pinned commit) +- `G1-ANDROID` — blocked in contracts/upstream-zag.json (non-available/partial prerequisite not upgraded at pinned commit) +- `G1-OBJC` — blocked in contracts/upstream-zag.json (non-available/partial prerequisite not upgraded at pinned commit) +- `G1-COM` — blocked in contracts/upstream-zag.json (non-available/partial prerequisite not upgraded at pinned commit) +- `G1-JNI` — blocked in contracts/upstream-zag.json (non-available/partial prerequisite not upgraded at pinned commit) +- `G1-CALLBACKS` — blocked in contracts/upstream-zag.json (non-available/partial prerequisite not upgraded at pinned commit) +- `G1-AGGREGATES` — blocked in contracts/upstream-zag.json (non-available/partial prerequisite not upgraded at pinned commit) +- `G1-RESOURCES` — blocked in contracts/upstream-zag.json (non-available/partial prerequisite not upgraded at pinned commit) +- `G1-DYNAMIC-LOAD` — blocked in contracts/upstream-zag.json (non-available/partial prerequisite not upgraded at pinned commit) +- `G1-CONCURRENCY` — blocked in contracts/upstream-zag.json (non-available/partial prerequisite not upgraded at pinned commit) +- `G1-PACKAGES` — blocked in contracts/upstream-zag.json (non-available/partial prerequisite not upgraded at pinned commit) +- `G1-RELOAD` — blocked in contracts/upstream-zag.json (non-available/partial prerequisite not upgraded at pinned commit) +- `G1-SOURCE-FIRST` — blocked in contracts/upstream-zag.json (non-available/partial prerequisite not upgraded at pinned commit) +- `G3-UNICODE` — not covered by current passed headless gates (contracts currently cover only text/image/primitive raster foundations) +- `G3-OPENTYPE` — not covered by current passed headless gates (contracts currently cover only text/image/primitive raster foundations) +- `G3-EDITING` — not covered by current passed headless gates (contracts currently cover only text/image/primitive raster foundations) +- `G3-FONTS` — not covered by current passed headless gates (contracts currently cover only text/image/primitive raster foundations) +- `G3-COLOR` — not covered by current passed headless gates (contracts currently cover only text/image/primitive raster foundations) +- `G3-SVG` — not covered by current passed headless gates (contracts currently cover only text/image/primitive raster foundations) +- `G3-PNG` — not covered by current passed headless gates (contracts currently cover only text/image/primitive raster foundations) +- `G3-SHADOWS` — not covered by current passed headless gates (contracts currently cover only text/image/primitive raster foundations) +- `G3-LIGHTING` — not covered by current passed headless gates (contracts currently cover only text/image/primitive raster foundations) +- `G3-GLASS` — not covered by current passed headless gates (contracts currently cover only text/image/primitive raster foundations) +- `G3-MOTION` — not covered by current passed headless gates (contracts currently cover only text/image/primitive raster foundations) +- `G3-REDUCED-MOTION` — not covered by current passed headless gates (contracts currently cover only text/image/primitive raster foundations) +- `G3-ASSET-PIPELINE` — not covered by current passed headless gates (contracts currently cover only text/image/primitive raster foundations) +- `G4-INPUT` — downstream/platform and developer-tool implementations missing in this repository or require native host implementations +- `G4-GESTURES` — downstream/platform and developer-tool implementations missing in this repository or require native host implementations +- `G4-ACCESSIBILITY` — downstream/platform and developer-tool implementations missing in this repository or require native host implementations +- `G4-CLI` — downstream/platform and developer-tool implementations missing in this repository or require native host implementations +- `G4-PREVIEW` — downstream/platform and developer-tool implementations missing in this repository or require native host implementations +- `G4-INSPECTORS` — downstream/platform and developer-tool implementations missing in this repository or require native host implementations +- `G4-GALLERY` — downstream/platform and developer-tool implementations missing in this repository or require native host implementations +- `G5-WAYLAND` — platforms.json marks linux delivery surface capabilities unavailable and no shell/backend implementation is present +- `G5-X11` — platforms.json marks linux delivery surface capabilities unavailable and no shell/backend implementation is present +- `G5-ATSPI` — platforms.json marks linux delivery surface capabilities unavailable and no shell/backend implementation is present +- `G5-LINUX-CPU` — platforms.json marks linux delivery surface capabilities unavailable and no shell/backend implementation is present +- `G5-LINUX-GPU` — platforms.json marks linux delivery surface capabilities unavailable and no shell/backend implementation is present +- `G5-LINUX-POLISH` — platforms.json marks linux delivery surface capabilities unavailable and no shell/backend implementation is present +- `G5-LINUX-FIDELITY` — platforms.json marks linux delivery surface capabilities unavailable and no shell/backend implementation is present +- `G5-LINUX-PACKAGE` — platforms.json marks linux delivery surface capabilities unavailable and no shell/backend implementation is present +- `G6-INVENTORY` — PrismStudio migration requires external write access and native shell replacement work in /home/micah/Desktop/Sylorlabs/PrismStudio +- `G6-DESIGN` — PrismStudio migration requires external write access and native shell replacement work in /home/micah/Desktop/Sylorlabs/PrismStudio +- `G6-SHELL` — PrismStudio migration requires external write access and native shell replacement work in /home/micah/Desktop/Sylorlabs/PrismStudio +- `G6-WORKFLOWS` — PrismStudio migration requires external write access and native shell replacement work in /home/micah/Desktop/Sylorlabs/PrismStudio +- `G6-VIEWPORT` — PrismStudio migration requires external write access and native shell replacement work in /home/micah/Desktop/Sylorlabs/PrismStudio +- `G6-DENSE-UI` — PrismStudio migration requires external write access and native shell replacement work in /home/micah/Desktop/Sylorlabs/PrismStudio +- `G6-MATERIALS` — PrismStudio migration requires external write access and native shell replacement work in /home/micah/Desktop/Sylorlabs/PrismStudio +- `G6-ASSETS` — PrismStudio migration requires external write access and native shell replacement work in /home/micah/Desktop/Sylorlabs/PrismStudio +- `G6-AUTOMATION` — PrismStudio migration requires external write access and native shell replacement work in /home/micah/Desktop/Sylorlabs/PrismStudio +- `G6-ACCESSIBILITY` — PrismStudio migration requires external write access and native shell replacement work in /home/micah/Desktop/Sylorlabs/PrismStudio +- `G6-SCREENSHOTS` — PrismStudio migration requires external write access and native shell replacement work in /home/micah/Desktop/Sylorlabs/PrismStudio +- `G6-PERFORMANCE` — PrismStudio migration requires external write access and native shell replacement work in /home/micah/Desktop/Sylorlabs/PrismStudio +- `G6-POLISH` — PrismStudio migration requires external write access and native shell replacement work in /home/micah/Desktop/Sylorlabs/PrismStudio +- `G7-MACOS` — requires completed milestones across G1, G5, G6 plus native host/test evidence in platform repos +- `G7-WINDOWS` — requires completed milestones across G1, G5, G6 plus native host/test evidence in platform repos +- `G7-IOS` — requires completed milestones across G1, G5, G6 plus native host/test evidence in platform repos +- `G7-ANDROID` — requires completed milestones across G1, G5, G6 plus native host/test evidence in platform repos +- `G7-MOBILE-REFERENCE` — requires completed milestones across G1, G5, G6 plus native host/test evidence in platform repos +- `G7-COMPONENT-PARITY` — requires completed milestones across G1, G5, G6 plus native host/test evidence in platform repos +- `G7-TEXT-PARITY` — requires completed milestones across G1, G5, G6 plus native host/test evidence in platform repos +- `G7-RECOVERY` — requires completed milestones across G1, G5, G6 plus native host/test evidence in platform repos +- `G7-PERFORMANCE` — requires completed milestones across G1, G5, G6 plus native host/test evidence in platform repos +- `G7-PACKAGING` — requires completed milestones across G1, G5, G6 plus native host/test evidence in platform repos +- `G7-ONE-POINT-ZERO` — requires completed milestones across G1, G5, G6 plus native host/test evidence in platform repos + +## Visual direction scope check + +- Pilot artifacts expected count: 324 +- Pilot generated: 324 +- Pilot expected: 324 +- Full expected matrix mode: 100800 +- Full generated: 0 +- Recommendation section present: true + +## Write-gate blockers + +- /home/micah/Desktop/Sylorlabs/zag: writable +- /home/micah/Desktop/Sylorlabs/PrismStudio: writable + +## Agent checklist + +This audit produced a canonical blocker checklist at: + +- [agent checklist](agent-checklist-2026-08-06.md) + +## Recommended next concrete actions + +1. Update /home/micah/Desktop/Sylorlabs/zag prerequisites (G1.*) at pinned compiler revision and re-run all downstream checks. +2. Implement Linux shell/AT-SPI/caps and a real rendering transport to satisfy G5. +3. Continue PrismStudio migration tasks only after read-write workspace is restored for /home/micah/Desktop/Sylorlabs/PrismStudio. +4. Resume full visual-direction render generation once native material pipeline is implemented; pilot artifacts are placeholders only. diff --git a/docs/evidence/headless-launch-showcase-2026-08-07.md b/docs/evidence/headless-launch-showcase-2026-08-07.md index 18f882e..5d976d9 100644 --- a/docs/evidence/headless-launch-showcase-2026-08-07.md +++ b/docs/evidence/headless-launch-showcase-2026-08-07.md @@ -1,7 +1,7 @@ # Headless launch showcase (2026-08-07) - Command: `./zagkit run --headless-only --show-ascii --open` -- Output image: [artifacts/launch/headless-reference.png](artifacts/launch/headless-reference.png) +- Output image: [artifacts/launch/headless-reference.png](../../artifacts/launch/headless-reference.png) - Image: 320 x 200 RGBA PNG - Exit: pass diff --git a/docs/evidence/linux-x11-preview-2026-08-08.md b/docs/evidence/linux-x11-preview-2026-08-08.md new file mode 100644 index 0000000..25c134c --- /dev/null +++ b/docs/evidence/linux-x11-preview-2026-08-08.md @@ -0,0 +1,39 @@ +# Linux X11 preview evidence — 2026-08-08 + +Status: **experimental transport proof**, not a polished component or Linux beta. + +## What passed + +- Zagkit built a native dynamic ELF with an explicit `libX11.so.6` dependency. +- `.auto` selected the X11 fallback and reported `active backend=x11`. +- The shell created and mapped a native window, presented Zagkit's CPU pixels, + synchronized the X connection, encoded a bounded capture, and cleaned up. +- `artifacts/launch/linux-preview-native.png` is 1120×720 RGBA and has SHA-256 + `d16554081823f93f8d36186a76037473b5e79ea4b5f663daf493c9ba8b255a3a`. +- The native capture is byte-identical to + `artifacts/launch/linux-preview-reference.png`, the deterministic CPU oracle. +- The mapped window was discovered as XID `0x06c00001`, resized to 1400×800, + rerendered across the full surface, and captured from the X server at + `artifacts/evidence/linux-x11-window-2026-08-08.png` (SHA-256 + `ceb68c9f800e34f596f9375c2eef86a50959ac53d89561aeaef09cfc128dec04`). +- The legacy title is ASCII-safe and `_NET_WM_NAME` contains the intended UTF-8 + title `Zagkit — Linux Preview`. + +## Upstream Zag issue fixed + +The first X11 launch exposed a compiler defect in nested dynamic C calls. When +a foreign call appeared under field assignment, Zag's expression stack could +leave `rsp` misaligned; aligning after pushing C stack arguments then displaced +argument seven and later. The fix lives in Zag's native lowering: preserve the +exact expression-stack pointer, align before System V argument placement, and +restore it after the call. Focused nine-argument field-assignment and deep X11 +aggregate-return fixtures both exited `42` with the fixed compiler stage. + +## Honest limits + +This frame is an early renderer/shell witness. It does not yet contain real +typography, icons, text shaping, controls, semantics, accessibility, IME, +clipboard, drag and drop, multi-window behavior, GPU presentation, Wayland, or +production materials and motion. Its visual quality does not satisfy the Linux +polish gate or the SwiftUI/Web UI competitive target. Those remain checked by +the project goal and agent checklist rather than being inferred from this image. diff --git a/docs/evidence/prismstudio-migration-inventory-2026-08-07.md b/docs/evidence/prismstudio-migration-inventory-2026-08-07.md new file mode 100644 index 0000000..baf9ae9 --- /dev/null +++ b/docs/evidence/prismstudio-migration-inventory-2026-08-07.md @@ -0,0 +1,255 @@ +# PrismStudio migration evidence inventory + +Generated: 2026-08-07 (workflow artifact) +Source: `/home/micah/Desktop/Sylorlabs/PrismStudio` + +## Source inventory + +- Source modules discovered: 49 +- Manifest sections discovered: 5 +- Probe files discovered by manifest: 129 +- Probe files declared by manifest heading: 128 +- MCP protocol tools discovered: 92 +- Command identifiers discovered: 46 +- Workspace sections discovered from source comments: 11 +- Hard-rule sets in AGENTS: 6 + +## Source module snapshot + +| Module | Role | +| --- | --- | +| agent.zag | headless agent RPC for CLI and MCP wrappers. | +| app.zag | application core: state, zones, tools, input, and the command | +| builder.zag | Engine Builder / Density Synthesizer (Masterplan §22). | +| capability.zag | explicit local-agent grants and append-only request audit. | +| commands.zag | stable command-id registry for palette + agent dispatch. | +| components.zag | the photonic hardware library. | +| demo.zag | reference photonic processor design (shared by GUI + agent). | +| device_model.zag | versioned physical assumptions with explicit provenance. | +| editops.zag | editing operations above the raw scene: move with waveguide | +| export.zag | deterministic human/machine-readable project artifacts. | +| fb.zag | software framebuffer for Zag Photonics Architect. | +| flash_ir.zag | Flash FIR v1 importer and photonic execution verifier. | +| fontdata.zag | 5x9 bitmap font rows (bits 4..0) | +| fontatlas.zag | glyph atlas packer: bakes fontdata rows into a cacheable | +| gpu_backend.zag | User-selectable auto/CPU/virtual/physical backend policy; explicit CPU/virtual choices do not open or probe DRM. | +| gpu_compute.zag | high-level GPU compute operations built on the verified | +| gpu_isa_display.zag | Compiler-ISA-executed virtual framebuffer with owned memory, ordered fences, complete-frame presentation, and no DRM access. | +| gpu_isa_raster.zag | Strict virtual GFX10.1 tiled raster execution for compiler-emitted clear, depth, clipping, and fixed-point alpha compositing, with CPU shadow fallback. | +| gpu_raster.zag | Compiler-bundle-gated tiled raster software model: clear, geometry, depth, clipping, compositing, per-tile fences, shadow comparison, double buffering, fallback, and mismatch input/image/diff/log/tuple evidence. | +| gpu_virtual_cert.zag | Host-contained certification runner: 10,000 fills, one million actual VM submissions, exact ownership/fences, 86,400 logical soak ticks, raster differentials, and explicitly non-physical evidence. | +| gpu_rt.zag | a pure-Zag AMDGPU runtime. No libc, no libdrm, no Mesa: | +| io_chunks.zag | chunked design I/O keyed by world 32³ chunks. | +| ioline.zag | buffered stdin reads (chunked, not byte-per-syscall). | +| limits.zag | centralized, named, documented resource ceilings for untrusted | +| main.zag | photonic CPU designer entry point. 100% Zag, no C anywhere. | +| math3d.zag | vectors, orbit camera, projection, and picking rays. | +| mcp.zag | native MCP stdio server (JSON-RPC + Content-Length framing). | +| optimizer.zag | Continuous Optical-Computation Optimizer (Photon Solver). | +| process_stack.zag | physical process-stack layer model (plate, part, guide heights). | +| rdna.zag | a hand-written RDNA1 (GFX10.1) machine-code emitter, in pure Zag. | +| routing.zag | waveguide routing engine. A* over the free voxel lattice, | +| scene.zag | the design database. The scene is stored as (a) a voxel | +| session.zag | live shared design: one .zpa for GUI + agent + MCP. | +| sim.zag | wave-state simulation. The scene's directed optical graph is | +| sim_region.zag | incremental simulation recompile. | +| strutil.zag | leak-free string helpers (s2..s6, fmt_i, fmt_f1) for ownership. | +| ternary.zag | balanced ternary optical logic. | +| tiles.zag | tile-based render cache for the 3D viewport. | +| timing.zag | static timing analysis over the routed photonic fabric (Masterplan | +| ui.zag | dark workbench theme + minimal immediate-mode widgets over fb.zag. | +| uilayer.zag | retained UI panel surfaces: cached pixels + state hashes (Section 3.12). | +| viewport.zag | the 3D viewport. Software-rasterized in Zag: | +| voxel.zag | integer lattice coordinates for the design grid. | +| workspace.zag | panel rendering + per-frame orchestration. | +| workspace_menu.zag | menu bar, command palette, status bar, context menu. | +| workspace_opt.zag | optimizer proposal panel + builder panel + apply paths. | +| workspace_settings.zag | modal dialogs, Flash FIR workspace, model browser, settings. | +| world.zag | sparse chunk-based spatial index for million-scale scenes. | +| x11.zag | a pure-Zag X11 client. No libc, no Xlib, no C anywhere: | + +## Probe manifest coverage + +- ✅ **Production tests — gated by `tools/verify.zag`**: declared 94, parsed 95 + - sample: `agent.zag`, `ast.zag`, `bounds_test.zag`, `boxselect_test.zag`, `builder_test.zag`, `camera_test.zag`, `components.zag`, `copypaste_test.zag`, `crash_recovery_test.zag`, `demo.zag`, `design_db_test.zag`, `dpi_test.zag`… +- ✅ **Hardware-only — require a real (ideally non-display) GPU; excluded from the safe suite**: declared 6, parsed 6 + - sample: `gpu_compute_test.zag`, `gpu_fill_test.zag`, `gpu_parallel_test.zag`, `gpu_submit_test.zag`, `gpu_test.zag`, `gpu_wg_test.zag` +- ✅ **Dev benchmarks — timing tools, not pass/fail gates (superseded by `tools/bench.zag`)**: declared 2, parsed 2 + - sample: `perf_test.zag`, `scale_test.zag` +- ✅ **Compiler probes — exercise a `znc` language feature, not PrismStudio**: declared 2, parsed 2 + - sample: `_repro_znc1.zag`, `break_test.zag` +- ✅ **Obsolete — early debug/exploration scratch; kept for history, not run and not production**: declared 24, parsed 24 + - sample: `agent_place2.zag`, `agent_twoline.zag`, `app.zag`, `components_only.zag`, `hashmap.zag`, `hm_dbg.zag`, `io_chunks.zag`, `lex.zag`, `move_dbg.zag`, `opt_ui_shot.zag`, `probe3.zag`, `probe4.zag`… + +## Workspace section map + +- 3.7: . A category is a lens, not a partition +- 3.2: unlabeled section +- 3.8: , clears after ~3 s +- 3.4: unlabeled section +- 3.9: type icon, name (inline-renamable), +- 3.6: the row becomes a text field +- 3.10: the row whose beam path is selected in 3D +- 3.11: unlabeled section +- 3.5: unlabeled section +- 3.12: unlabeled section +- 20.5: . Kept off the interaction path: it + +## PrismStudio command surface + +- `cmd_none` = `0` +- `cmd_new` = `1` +- `cmd_open` = `2` +- `cmd_save` = `3` +- `cmd_save_as` = `4` +- `cmd_quit` = `5` +- `cmd_undo` = `10` +- `cmd_redo` = `11` +- `cmd_duplicate` = `12` +- `cmd_delete` = `13` +- `cmd_deselect` = `14` +- `cmd_copy` = `15` +- `cmd_paste` = `16` +- `cmd_group` = `17` +- `cmd_ungroup` = `18` +- `cmd_rename` = `19` +- `cmd_frame_selected` = `20` +- `cmd_frame_all` = `21` +- `cmd_view_top` = `22` +- `cmd_view_front` = `23` +- `cmd_view_right` = `24` +- `cmd_view_perspective` = `25` +- `cmd_toggle_ortho` = `26` +- `cmd_cycle_ui_scale` = `27` +- `cmd_reset_view` = `28` +- `cmd_section_view` = `29` +- `cmd_shortcuts` = `30` +- `cmd_demo` = `31` +- `cmd_open_flash_reference` = `32` +- `cmd_model_provenance` = `33` +- `cmd_recover_autosave` = `34` +- `cmd_tool_select` = `40` +- `cmd_tool_route` = `41` +- `cmd_sim_play_toggle` = `42` +- `cmd_optimizer` = `43` +- `cmd_settings` = `44` +- `cmd_palette` = `45` +- `cmd_tool_measure` = `46` +- `cmd_toggle_reduced_motion` = `47` +- `cmd_toggle_object_snap` = `48` +- `cmd_flash_workspace` = `49` +- `cmd_place_base` = `50` +- `cmd_builder` = `51` +- `cmd_toggle_visibility` = `70` +- `cmd_toggle_lock` = `71` +- `cmd_palette_jump_base` = `1000` + +## MCP tools + +- `prismstudio_command` (mutation) +- `prismstudio_mutate` (query) +- `prismstudio_help` (query) +- `prismstudio_capabilities` (query) +- `prismstudio_ping` (query) +- `prismstudio_new` (query) +- `prismstudio_demo` (query) +- `prismstudio_list` (query) +- `prismstudio_list_files` (query) +- `prismstudio_get` (query) +- `prismstudio_select` (query) +- `prismstudio_can_place` (query) +- `prismstudio_undo` (query) +- `prismstudio_redo` (query) +- `prismstudio_view` (query) +- `prismstudio_camera` (query) +- `prismstudio_render_at` (query) +- `prismstudio_pick` (query) +- `prismstudio_place_here` (query) +- `prismstudio_place_on` (query) +- `prismstudio_place` (query) +- `prismstudio_route` (query) +- `prismstudio_delete` (query) +- `prismstudio_sim_step` (query) +- `prismstudio_sim_state` (query) +- `prismstudio_sim` (query) +- `prismstudio_move` (query) +- `prismstudio_rotate` (query) +- `prismstudio_disconnect` (query) +- `prismstudio_reroute` (query) +- `prismstudio_inspect` (query) +- `prismstudio_close` (query) +- `prismstudio_build` (query) +- `prismstudio_preview` (query) +- `prismstudio_simstream` (query) +- `prismstudio_timing` (query) +- `prismstudio_del` (query) +- `prismstudio_sel` (query) +- `prismstudio_save` (query) +- `prismstudio_open` (query) +- `prismstudio_render` (query) +- `prismstudio_export` (query) +- `prismstudio_diagnostics` (query) +- `prismstudio_details` (query) +- `prismstudio_audit` (query) +- `prismstudio_logs` (query) +- `prismstudio_uilog` (query) +- `prismstudio_trace` (query) +- `prismstudio_flash_import` (query) +- `prismstudio_flash_verify` (query) +- `prismstudio_ui_list` (query) +- `prismstudio_ui_screenshot` (query) +- `prismstudio_ui_activate` (query) +- `prismstudio_optimizer_apply` (query) +- `prismstudio_optimizer_accept` (query) +- `prismstudio_optimizer_decline` (query) +- `prismstudio_optimizer_ignore` (query) +- `prismstudio_optimizer_details` (query) +- `prismstudio_builder_generate` (query) +- `prismstudio_builder_apply` (query) +- `prismstudio_builder_accept` (query) +- `prismstudio_builder_decline` (query) +- `prismstudio_builder_ignore` (query) +- `prismstudio_builder_details` (query) +- `prismstudio_copy` (query) +- `prismstudio_paste` (query) +- `prismstudio_duplicate` (query) +- `prismstudio_group` (query) +- `prismstudio_ungroup` (query) +- `prismstudio_rename` (query) +- `prismstudio_toggle_visibility` (query) +- `prismstudio_toggle_lock` (query) +- `prismstudio_select_add` (query) +- `prismstudio_select_remove` (query) +- `prismstudio_select_clear` (query) +- `prismstudio_view_frame` (query) +- `prismstudio_view_toggle` (query) +- `prismstudio_panel` (query) +- `prismstudio_ui_click` (query) +- `prismstudio_ui_type` (query) +- `prismstudio_camera_orbit` (query) +- `prismstudio_camera_pan` (query) +- `prismstudio_camera_zoom` (query) +- `prismstudio_model_status` (query) +- `prismstudio_model_migrate` (query) +- `prismstudio_test` (query) +- `prismstudio_status` (query) +- `prismstudio_coords` (query) +- `prismstudio_process_stack` (query) +- `prismstudio_power` (query) +- `prismstudio_perf` (query) +- `prismstudio_kinds` (query) + +## Hard rules carried into migration planning + +- No hardcoded physical constants +- Fix Zag at the compiler, never work around it in the app +- No committing binaries +- No `CAPS=all` default +- Math constant precision +- String ownership + +## Open ownership questions (must close before G6-** tasks) + +- Confirm that every visible or keyboard-operable control in the target PrismStudio migration matrix has a mapped Zagkit replacement before shell replacement. +- Confirm each protocol transport behavior above is represented by the same automation contract in Zagkit Talkback (not via pixel fallback). +- Confirm each critical visual asset state (lighting/shadows/glass, scale variants, transparency and reduced-motion variants) has a matching native fixture policy before finalizing visual direction selection. diff --git a/examples/linux_preview.zag b/examples/linux_preview.zag new file mode 100644 index 0000000..87583bc --- /dev/null +++ b/examples/linux_preview.zag @@ -0,0 +1,89 @@ +@import("../src/platform/linux/x11.zag") +@import("../src/showcase/linux_preview_scene.zag") +@import("../src/render/png_encode.zag") + +fn preview_render(host: *LinuxX11Window, width: i32, height: i32, + capture_path: []u8) i32 { + let list: DisplayList = linux_preview_scene(width, height); + if (display_list_verify(list) != DisplayError.none) { + display_list_free(&list); + _zag_eprintln("zagkit: Linux preview display list verification failed"); + return 0; + } + let raster: CpuRasterResult = cpu_rasterize(list, width, height); + if (raster.error != CpuRasterError.none) { + cpu_raster_free(&raster); + display_list_free(&list); + _zag_eprintln("zagkit: Linux preview CPU raster failed"); + return 0; + } + let presented: i32 = linux_x11_present(host, raster.surface); + if (presented != 0 && capture_path.len > 0) { + let png: PngEncodeResult = png_encode_surface(raster.surface); + if (png.error == PngEncodeError.none) { + if (_zag_write_file(capture_path, png.bytes.data[0..png.bytes.len]) != 0) { + _zag_eprintln("zagkit: failed to write Linux preview capture"); + } + } + png_encode_free(&png); + } + cpu_raster_free(&raster); + display_list_free(&list); + return presented; +} + +fn preview_clamp_dimension(value: i32, minimum: i32, maximum: i32) i32 { + if (value < minimum) { return minimum; } + if (value > maximum) { return maximum; } + return value; +} + +fn main() i32 { + let capture_once: i32 = 0; + let capture_path: []u8 = ""; + if (_zag_argc() > 1 && _zag_strcmp(_zag_arg(1), "--capture-once") == 1) { + if (_zag_argc() != 3) { + _zag_eprintln("usage: linux-preview [--capture-once output.png]"); + return 2; + } + capture_once = 1; + capture_path = _zag_arg(2); + } + + let wayland_available: i32 = (_zag_getenv("WAYLAND_DISPLAY").len > 0) as i32; + let host: LinuxX11Window = linux_x11_open(1120, 720, + "Zagkit — Linux Preview", wayland_available); + _zag_print("zagkit: requested backend=auto active backend="); + _zag_println(platform_backend_name(host.capabilities.active)); + if (host.capabilities.fallback_recorded != 0) { + _zag_print("zagkit: backend fallback: "); + _zag_println(host.capabilities.fallback_reason); + } + if (host.open == 0) { + _zag_eprintln("zagkit: native Linux window unavailable; capability report remains fail-closed"); + linux_x11_close(&host); + return 3; + } + if (preview_render(&host, 1120, 720, capture_path) == 0) { + linux_x11_close(&host); + return 4; + } + if (capture_once != 0) { + linux_x11_close(&host); + return 0; + } + + while (host.open != 0) { + let event: LinuxX11Event = linux_x11_next_event(&host); + if (event.kind == LinuxX11EventKind.close) { host.open = 0; } + else if (event.kind == LinuxX11EventKind.expose) { + _ = preview_render(&host, host.width, host.height, ""); + } else if (event.kind == LinuxX11EventKind.resize) { + let next_width: i32 = preview_clamp_dimension(event.width, 480, 1920); + let next_height: i32 = preview_clamp_dimension(event.height, 320, 1200); + _ = preview_render(&host, next_width, next_height, ""); + } + } + linux_x11_close(&host); + return 0; +} diff --git a/src/platform/capabilities.zag b/src/platform/capabilities.zag new file mode 100644 index 0000000..47ae97d --- /dev/null +++ b/src/platform/capabilities.zag @@ -0,0 +1,100 @@ +enum PlatformSupport { + supported, + experimental, + unavailable, +} + +enum PlatformBackend { + none, + headless, + wayland, + x11, + metal, + d3d12, + android, +} + +struct PlatformCapability { + status: PlatformSupport, + reason: []u8, +} + +struct PlatformCapabilities { + requested: PlatformBackend, + active: PlatformBackend, + window_surface: PlatformCapability, + cpu_presentation: PlatformCapability, + gpu_presentation: PlatformCapability, + accessibility: PlatformCapability, + text_input: PlatformCapability, + fallback_recorded: i32, + fallback_reason: []u8, +} + +fn platform_capability(status: PlatformSupport, reason: []u8) PlatformCapability { + return PlatformCapability{ .status = status, .reason = reason }; +} + +fn platform_capabilities_headless() PlatformCapabilities { + return PlatformCapabilities{ + .requested = PlatformBackend.headless, + .active = PlatformBackend.headless, + .window_surface = platform_capability(PlatformSupport.unavailable, + "headless execution has no native window surface"), + .cpu_presentation = platform_capability(PlatformSupport.supported, + "deterministic CPU raster output is active"), + .gpu_presentation = platform_capability(PlatformSupport.unavailable, + "no GPU transport was requested"), + .accessibility = platform_capability(PlatformSupport.unavailable, + "headless semantics are not a native accessibility adapter"), + .text_input = platform_capability(PlatformSupport.unavailable, + "headless execution has no native IME"), + .fallback_recorded = 0, + .fallback_reason = "", + }; +} + +fn platform_capabilities_linux_auto(wayland_available: i32, + x11_available: i32) PlatformCapabilities { + let result: PlatformCapabilities = platform_capabilities_headless(); + result.requested = PlatformBackend.none; + if (wayland_available != 0) { + if (x11_available != 0) { + result.active = PlatformBackend.x11; + result.window_surface = platform_capability(PlatformSupport.experimental, + "X11 fallback is active; the Wayland shell is not implemented yet"); + result.cpu_presentation = platform_capability(PlatformSupport.experimental, + "CPU pixels are presented through the X11 fallback"); + result.fallback_recorded = 1; + result.fallback_reason = "Wayland was detected but is unavailable; selected X11 fallback"; + } else { + result.active = PlatformBackend.headless; + result.fallback_recorded = 1; + result.fallback_reason = "Wayland was detected but its shell is unavailable and no X11 fallback exists"; + } + return result; + } + if (x11_available != 0) { + result.active = PlatformBackend.x11; + result.window_surface = platform_capability(PlatformSupport.experimental, + "X11 fallback is active; this is not Wayland equivalence"); + result.cpu_presentation = platform_capability(PlatformSupport.experimental, + "CPU pixels are presented through the X11 fallback"); + return result; + } + result.fallback_recorded = 1; + result.fallback_reason = "no supported Linux display server endpoint is available"; + return result; +} + +fn platform_backend_name(backend: PlatformBackend) []u8 { + return switch (backend) { + .none => "none", + .headless => "headless", + .wayland => "wayland", + .x11 => "x11", + .metal => "metal", + .d3d12 => "d3d12", + .android => "android", + }; +} diff --git a/src/platform/linux/x11.zag b/src/platform/linux/x11.zag new file mode 100644 index 0000000..e1cbebc --- /dev/null +++ b/src/platform/linux/x11.zag @@ -0,0 +1,351 @@ +@import("../capabilities.zag") +@import("../../render/cpu_raster.zag") + +// Xlib is used only as the public Linux window-system transport. Layout, +// rendering, resources, state, semantics, and pixels remain owned by Zagkit. +// This fallback never promotes itself to Wayland or accessibility support. +extern fn XOpenDisplay(name: *u8) *u8; +extern fn XDefaultScreen(display: *u8) i32; +extern fn XRootWindow(display: *u8, screen: i32) i64; +extern fn XDefaultVisual(display: *u8, screen: i32) *u8; +extern fn XDefaultDepth(display: *u8, screen: i32) i32; +extern fn XCreateSimpleWindow(display: *u8, parent: i64, x: i32, y: i32, + width: i32, height: i32, border_width: i32, border: i64, + background: i64) i64; +extern fn XCreateGC(display: *u8, drawable: i64, value_mask: i64, + values: *u8) *u8; +extern fn XSelectInput(display: *u8, window: i64, event_mask: i64) i32; +extern fn XStoreName(display: *u8, window: i64, name: *u8) i32; +extern fn XChangeProperty(display: *u8, window: i64, property: i64, + property_type: i64, format: i32, mode: i32, data: *u8, + element_count: i32) i32; +extern fn XInternAtom(display: *u8, name: *u8, only_if_exists: i32) i64; +extern fn XSetWMProtocols(display: *u8, window: i64, protocols: *i64, + count: i32) i32; +extern fn XMapWindow(display: *u8, window: i64) i32; +extern fn XCreateImage(display: *u8, visual: *u8, depth: i32, format: i32, + offset: i32, data: *u8, width: i32, height: i32, bitmap_pad: i32, + bytes_per_line: i32) *u8; +extern fn XPutImage(display: *u8, drawable: i64, gc: *u8, image: *u8, + src_x: i32, src_y: i32, dest_x: i32, dest_y: i32, + width: i32, height: i32) i32; +extern fn XPending(display: *u8) i32; +extern fn XNextEvent(display: *u8, event: *u8) i32; +extern fn XGetGeometry(display: *u8, drawable: i64, root_return: *i64, + x_return: *i32, y_return: *i32, width_return: *i32, + height_return: *i32, border_width_return: *i32, + depth_return: *i32) i32; +extern fn XFlush(display: *u8) i32; +extern fn XSync(display: *u8, discard: i32) i32; +extern fn XDestroyImage(image: *u8) i32; +extern fn XFreeGC(display: *u8, gc: *u8) i32; +extern fn XDestroyWindow(display: *u8, window: i64) i32; +extern fn XCloseDisplay(display: *u8) i32; + +enum LinuxX11Error { + none, + display_unavailable, + visual_unavailable, + window_creation_failed, + image_creation_failed, + presentation_failed, +} + +enum LinuxX11EventKind { + none, + expose, + resize, + close, +} + +struct LinuxX11Event { + kind: LinuxX11EventKind, + width: i32, + height: i32, +} + +struct LinuxX11Window { + display: *u8, + visual: *u8, + window: i64, + gc: *u8, + image: *u8, + image_data: *u8, + event_data: *u8, + delete_atom: i64, + depth: i32, + width: i32, + height: i32, + open: i32, + error: LinuxX11Error, + capabilities: PlatformCapabilities, +} + +fn linux_x11_null() *u8 { return null as *u8; } + +fn linux_x11_c_string(value: []u8) *u8 { + let out: *u8 = _zag_malloc(value.len + 1) as *u8; + let i: i32 = 0; + while (i < value.len) { out[i] = value[i]; i = i + 1; } + out[value.len] = 0; + return out; +} + +fn linux_x11_empty() LinuxX11Window { + return LinuxX11Window{ + .display = linux_x11_null(), + .visual = linux_x11_null(), + .window = 0, + .gc = linux_x11_null(), + .image = linux_x11_null(), + .image_data = linux_x11_null(), + .event_data = linux_x11_null(), + .delete_atom = 0, + .depth = 0, + .width = 0, + .height = 0, + .open = 0, + .error = LinuxX11Error.none, + .capabilities = platform_capabilities_linux_auto(0, 1), + }; +} + +fn linux_x11_release_image(host: *LinuxX11Window) void { + unsafe { + if (host.*.image != linux_x11_null()) { + // XDestroyImage normally owns `data`. Zagkit allocated these bytes, + // so detach them before asking Xlib to destroy only its descriptor. + let words: *i64 = host.*.image as *i64; + words[2] = 0; + _ = XDestroyImage(host.*.image); + host.*.image = linux_x11_null(); + } + if (host.*.image_data != linux_x11_null()) { + _zag_free(host.*.image_data as *i8); + host.*.image_data = linux_x11_null(); + } + } +} + +fn linux_x11_copy_surface(host: *LinuxX11Window, surface: CpuSurface) i32 { + if (surface.width <= 0 || surface.height <= 0 || + surface.pixels.len != surface.width * surface.height * 4) { return 0; } + let pixel_count: i64 = (surface.width as i64) * (surface.height as i64); + if (pixel_count <= 0 || pixel_count > 67108864) { return 0; } + unsafe { + if (host.*.image == linux_x11_null() || host.*.width != surface.width || + host.*.height != surface.height) { + linux_x11_release_image(host); + host.*.image_data = _zag_malloc((pixel_count * 4) as i32) as *u8; + if (host.*.image_data == linux_x11_null()) { + host.*.error = LinuxX11Error.image_creation_failed; + return 0; + } + host.*.image = XCreateImage(host.*.display, host.*.visual, + host.*.depth, 2, 0, host.*.image_data, surface.width, + surface.height, 32, surface.width * 4); + if (host.*.image == linux_x11_null()) { + _zag_free(host.*.image_data as *i8); + host.*.image_data = linux_x11_null(); + host.*.error = LinuxX11Error.image_creation_failed; + return 0; + } + host.*.width = surface.width; + host.*.height = surface.height; + } + let i: i64 = 0; + while (i < pixel_count) { + let source: i64 = i * 4; + let destination: i64 = source; + // The CPU oracle is RGBA8. Common little-endian X11 TrueColor + // visuals consume BGRX bytes for the 0x00RRGGBB masks. + host.*.image_data[destination] = surface.pixels.data[source + 2]; + host.*.image_data[destination + 1] = surface.pixels.data[source + 1]; + host.*.image_data[destination + 2] = surface.pixels.data[source]; + host.*.image_data[destination + 3] = 0; + i = i + 1; + } + } + return 1; +} + +fn linux_x11_open(width: i32, height: i32, title: []u8, + wayland_available: i32) LinuxX11Window { + let host: LinuxX11Window = linux_x11_empty(); + host.capabilities = platform_capabilities_linux_auto(wayland_available, 1); + unsafe { + host.display = XOpenDisplay(linux_x11_null()); + if (host.display == linux_x11_null()) { + host.error = LinuxX11Error.display_unavailable; + host.capabilities = platform_capabilities_linux_auto(wayland_available, 0); + return host; + } + let screen: i32 = XDefaultScreen(host.display); + host.visual = XDefaultVisual(host.display, screen); + host.depth = XDefaultDepth(host.display, screen); + if (host.visual == linux_x11_null() || host.depth < 24) { + host.error = LinuxX11Error.visual_unavailable; + _ = XCloseDisplay(host.display); + host.display = linux_x11_null(); + return host; + } + let root: i64 = XRootWindow(host.display, screen); + host.window = XCreateSimpleWindow(host.display, root, 64, 64, + width, height, 0, 0, 0x080b14); + if (host.window == 0) { + host.error = LinuxX11Error.window_creation_failed; + _ = XCloseDisplay(host.display); + host.display = linux_x11_null(); + return host; + } + host.gc = XCreateGC(host.display, host.window, 0, linux_x11_null()); + if (host.gc == linux_x11_null()) { + host.error = LinuxX11Error.window_creation_failed; + _ = XDestroyWindow(host.display, host.window); + _ = XCloseDisplay(host.display); + host.display = linux_x11_null(); + return host; + } + // Exposure, StructureNotify, KeyPress, ButtonPress/Release, pointer + // motion, and focus changes are selected even before routing all of + // them, so the shell contract can grow without changing host creation. + _ = XSelectInput(host.display, host.window, + 32768 + 131072 + 1 + 4 + 8 + 64 + 2097152); + // WM_NAME is an old Latin-1 property. Keep its fallback ASCII-clean, + // then publish the real title through EWMH's UTF8_STRING contract. + let fallback_title: *u8 = linux_x11_c_string("Zagkit - Linux Preview"); + _ = XStoreName(host.display, host.window, fallback_title); + _zag_free(fallback_title as *i8); + let net_name_text: *u8 = linux_x11_c_string("_NET_WM_NAME"); + let utf8_text: *u8 = linux_x11_c_string("UTF8_STRING"); + let utf8_title: *u8 = linux_x11_c_string(title); + let net_name_atom: i64 = XInternAtom(host.display, net_name_text, 0); + let utf8_atom: i64 = XInternAtom(host.display, utf8_text, 0); + _zag_free(net_name_text as *i8); + _zag_free(utf8_text as *i8); + if (net_name_atom != 0 && utf8_atom != 0) { + _ = XChangeProperty(host.display, host.window, net_name_atom, + utf8_atom, 8, 0, utf8_title, title.len as i32); + } + _zag_free(utf8_title as *i8); + + let protocols_name: *u8 = linux_x11_c_string("WM_PROTOCOLS"); + let delete_name: *u8 = linux_x11_c_string("WM_DELETE_WINDOW"); + let protocols_atom: i64 = XInternAtom(host.display, protocols_name, 0); + host.delete_atom = XInternAtom(host.display, delete_name, 0); + _zag_free(protocols_name as *i8); + _zag_free(delete_name as *i8); + if (protocols_atom != 0 && host.delete_atom != 0) { + let atom_storage: *i64 = _zag_malloc(8) as *i64; + atom_storage[0] = host.delete_atom; + _ = XSetWMProtocols(host.display, host.window, atom_storage, 1); + _zag_free(atom_storage as *i8); + } + host.event_data = _zag_malloc(192) as *u8; + if (host.event_data == linux_x11_null()) { + host.error = LinuxX11Error.window_creation_failed; + _ = XFreeGC(host.display, host.gc); + _ = XDestroyWindow(host.display, host.window); + _ = XCloseDisplay(host.display); + host.display = linux_x11_null(); + return host; + } + host.width = width; + host.height = height; + host.open = 1; + _ = XMapWindow(host.display, host.window); + _ = XSync(host.display, 0); + } + return host; +} + +fn linux_x11_present(host: *LinuxX11Window, surface: CpuSurface) i32 { + if (host.*.open == 0 || linux_x11_copy_surface(host, surface) == 0) { + host.*.error = LinuxX11Error.presentation_failed; + return 0; + } + unsafe { + // XPutImage's integer return is not a success Status contract. Xlib + // reports protocol failures through its error path, so synchronize the + // request before declaring this frame presented. + _ = XPutImage(host.*.display, host.*.window, host.*.gc, + host.*.image, 0, 0, 0, 0, surface.width, surface.height); + _ = XSync(host.*.display, 0); + } + return 1; +} + +fn linux_x11_decode_event(host: *LinuxX11Window) LinuxX11Event { + let result: LinuxX11Event = LinuxX11Event{ + .kind = LinuxX11EventKind.none, + .width = host.*.width, + .height = host.*.height, + }; + unsafe { + let event_type: i32 = (host.*.event_data[0] as i32) & 127; + if (event_type == 12) { result.kind = LinuxX11EventKind.expose; } + else if (event_type == 22) { + // Query the server for authoritative geometry instead of + // duplicating XConfigureEvent's C-union byte layout in toolkit + // code. Each output has its own initialized Zag slot, so the C + // ABI's 32-bit writes are represented without aliasing. + let root_return: i64 = 0; + let x_return: i32 = 0; + let y_return: i32 = 0; + let width_return: i32 = 0; + let height_return: i32 = 0; + let border_return: i32 = 0; + let depth_return: i32 = 0; + if (XGetGeometry(host.*.display, host.*.window, &root_return, + &x_return, &y_return, &width_return, &height_return, + &border_return, &depth_return) != 0) { + result.width = width_return; + result.height = height_return; + if (result.width > 0 && result.height > 0 && + (result.width != host.*.width || result.height != host.*.height)) { + result.kind = LinuxX11EventKind.resize; + } + } + } else if (event_type == 33) { + let words64: *i64 = host.*.event_data as *i64; + if (words64[7] == host.*.delete_atom) { + result.kind = LinuxX11EventKind.close; + host.*.open = 0; + } + } + } + return result; +} + +fn linux_x11_next_event(host: *LinuxX11Window) LinuxX11Event { + if (host.*.open == 0) { + return LinuxX11Event{ .kind = LinuxX11EventKind.close, + .width = host.*.width, .height = host.*.height }; + } + unsafe { _ = XNextEvent(host.*.display, host.*.event_data); } + return linux_x11_decode_event(host); +} + +fn linux_x11_pending(host: *LinuxX11Window) i32 { + if (host.*.open == 0) { return 0; } + unsafe { return XPending(host.*.display); } +} + +fn linux_x11_close(host: *LinuxX11Window) void { + unsafe { + linux_x11_release_image(host); + if (host.*.event_data != linux_x11_null()) { + _zag_free(host.*.event_data as *i8); + host.*.event_data = linux_x11_null(); + } + if (host.*.display != linux_x11_null()) { + if (host.*.gc != linux_x11_null()) { _ = XFreeGC(host.*.display, host.*.gc); } + if (host.*.window != 0) { _ = XDestroyWindow(host.*.display, host.*.window); } + _ = XCloseDisplay(host.*.display); + } + } + host.*.display = linux_x11_null(); + host.*.gc = linux_x11_null(); + host.*.window = 0; + host.*.open = 0; +} diff --git a/src/render/cpu_raster.zag b/src/render/cpu_raster.zag index b6ab9cb..86d882a 100644 --- a/src/render/cpu_raster.zag +++ b/src/render/cpu_raster.zag @@ -453,6 +453,88 @@ fn cpu_fill_rect(surface: *CpuSurface, raw_bounds: Rect, clip: Rect, paint: Pain return touched; } +fn cpu_rounded_contains(bounds: Rect, radius: i64, x: i64, y: i64) i32 { + let inner_left: i64 = bounds.x + radius; + let inner_right: i64 = bounds.x + bounds.width - radius; + let inner_top: i64 = bounds.y + radius; + let inner_bottom: i64 = bounds.y + bounds.height - radius; + if ((x >= inner_left && x <= inner_right) || + (y >= inner_top && y <= inner_bottom)) { return 1; } + let center_x: i64 = inner_left; + let center_y: i64 = inner_top; + if (x > inner_right) { center_x = inner_right; } + if (y > inner_bottom) { center_y = inner_bottom; } + let dx: i64 = x - center_x; + let dy: i64 = y - center_y; + return (dx * dx + dy * dy <= radius * radius) as i32; +} + +fn cpu_fill_rounded_rect(surface: *CpuSurface, raw_bounds: Rect, + raw_radius: i64, clip: Rect, transform: Transform, + paint: Paint) CpuRasterResult { + let shape: Rect = cpu_transform_rect(raw_bounds, transform); + let bounds: Rect = cpu_rect_intersection( + cpu_rect_intersection(shape, clip), + rect(0, 0, (surface.*.width as i64) * unit_scale(), + (surface.*.height as i64) * unit_scale()) + ); + let scale: i64 = transform.scale_x; + if (transform.scale_y < scale) { scale = transform.scale_y; } + let radius: i64 = (raw_radius * scale) / unit_scale(); + if (bounds.width <= 0 || bounds.height <= 0) { + return cpu_result(surface.*, CpuRasterError.none, 0 - 1, 0, 0); + } + if (radius <= 0 || radius > 1000000000 || radius * 2 > shape.width || + radius * 2 > shape.height) { + return cpu_result(surface.*, CpuRasterError.invalid_geometry, 0 - 1, 0, 0); + } + let first_x: i32 = (bounds.x / unit_scale()) as i32; + let first_y: i32 = (bounds.y / unit_scale()) as i32; + let last_x: i32 = ((bounds.x + bounds.width + unit_scale() - 1) / + unit_scale()) as i32; + let last_y: i32 = ((bounds.y + bounds.height + unit_scale() - 1) / + unit_scale()) as i32; + let pixel_count: i64 = ((last_x - first_x) as i64) * + ((last_y - first_y) as i64); + let sample_axis: i32 = 4; + let samples: i64 = (sample_axis as i64) * (sample_axis as i64); + if (pixel_count <= 0 || pixel_count > 50000000 / samples) { + return cpu_result(surface.*, CpuRasterError.work_limit, 0 - 1, 0, 0); + } + let touched: i64 = 0; + let y: i32 = first_y; + while (y < last_y) { + let x: i32 = first_x; + while (x < last_x) { + let inside: i64 = 0; + let sy: i32 = 0; + while (sy < sample_axis) { + let sample_y: i64 = (y as i64) * unit_scale() + + ((sy as i64) * unit_scale() + unit_scale() / 2) / + (sample_axis as i64); + let sx: i32 = 0; + while (sx < sample_axis) { + let sample_x: i64 = (x as i64) * unit_scale() + + ((sx as i64) * unit_scale() + unit_scale() / 2) / + (sample_axis as i64); + inside = inside + cpu_rounded_contains(shape, radius, + sample_x, sample_y) as i64; + sx = sx + 1; + } + sy = sy + 1; + } + if (inside > 0) { + cpu_blend_pixel(surface, x, y, paint, + (inside * 4096) / samples); + touched = touched + 1; + } + x = x + 1; + } + y = y + 1; + } + return cpu_result(surface.*, CpuRasterError.none, 0 - 1, 0, touched); +} + fn cpu_rect_pixel_coverage(bounds: Rect, x: i32, y: i32) i64 { if (bounds.width <= 0 || bounds.height <= 0) { return 0; } let pixel_left: i64 = (x as i64) * unit_scale(); @@ -745,6 +827,15 @@ fn cpu_rasterize(list: DisplayList, width: i32, height: i32) CpuRasterResult { return cpu_result(surface, CpuRasterError.invalid_geometry, i, rendered_ops, pixels_touched); } pixels_touched = pixels_touched + cpu_fill_rect(&surface, transformed, current.clip, op.paint); + } else if (op.kind == DisplayOpKind.fill_rounded_rect) { + let rounded: CpuRasterResult = cpu_fill_rounded_rect(&surface, + op.bounds, op.parameter, current.clip, current.transform, op.paint); + if (rounded.error != CpuRasterError.none) { + free[CpuRasterState](&stack); + return cpu_result(surface, rounded.error, i, + rendered_ops, pixels_touched); + } + pixels_touched = pixels_touched + rounded.pixels_touched; } else if (op.kind == DisplayOpKind.stroke_rect) { let low: i64 = op.paint.stroke_width / 2; let expanded: Rect = rect( diff --git a/src/render/display_list.zag b/src/render/display_list.zag index 7bd480b..9f401c6 100644 --- a/src/render/display_list.zag +++ b/src/render/display_list.zag @@ -18,6 +18,7 @@ enum DisplayOpKind { begin_layer, end_layer, apply_effect, + fill_rounded_rect, } enum DisplayError { @@ -252,6 +253,7 @@ fn display_expected_resource_kind(kind: DisplayOpKind) i64 { .stroke_rect => 0 - 1, .begin_layer => 0 - 1, .end_layer => 0 - 1, + .fill_rounded_rect => 0 - 1, }; } @@ -281,6 +283,7 @@ fn display_resource_payload_valid(resource: RenderResource) i32 { fn display_op_requires_bounds(kind: DisplayOpKind) i32 { return (kind == DisplayOpKind.clip_rect || kind == DisplayOpKind.fill_rect || + kind == DisplayOpKind.fill_rounded_rect || kind == DisplayOpKind.stroke_rect || kind == DisplayOpKind.draw_image || kind == DisplayOpKind.begin_layer) as i32; } @@ -312,6 +315,11 @@ fn display_list_push(list: *DisplayList, op: DisplayOp) DisplayError { if (op.kind == DisplayOpKind.stroke_rect && op.paint.stroke_width <= 0) { return display_fail(list, DisplayError.invalid_parameter); } + if (op.kind == DisplayOpKind.fill_rounded_rect && + (op.parameter <= 0 || op.parameter * 2 > op.bounds.width || + op.parameter * 2 > op.bounds.height)) { + return display_fail(list, DisplayError.invalid_parameter); + } if (op.kind == DisplayOpKind.draw_image && (op.paint.red != 0 || op.paint.green != 0 || op.paint.blue != 0 || op.paint.stroke_width != unit_scale())) { diff --git a/src/render/display_list_codec.zag b/src/render/display_list_codec.zag index a69c907..7c37369 100644 --- a/src/render/display_list_codec.zag +++ b/src/render/display_list_codec.zag @@ -29,7 +29,7 @@ struct DisplayDecodeResult { error_offset: i64, } -fn display_codec_version() i64 { return 2; } +fn display_codec_version() i64 { return 3; } fn display_codec_header_size() i64 { return 76; } fn display_codec_resource_header_size() i64 { return 64; } fn display_codec_op_size() i64 { return 160; } @@ -256,7 +256,7 @@ fn display_list_decode(bytes: ArrayList[u8]) DisplayDecodeResult { let i: i64 = 0; while (i < count) { let kind_code: i64 = display_read_i64(bytes, offset); - if (kind_code < 0 || kind_code > 11) { + if (kind_code < 0 || kind_code > 12) { return display_decode_fail(list, DisplayCodecError.invalid_kind, offset); } let op: DisplayOp = display_op(kind_code as DisplayOpKind, node_key_scoped( diff --git a/src/showcase/linux_preview_scene.zag b/src/showcase/linux_preview_scene.zag new file mode 100644 index 0000000..9976efa --- /dev/null +++ b/src/showcase/linux_preview_scene.zag @@ -0,0 +1,252 @@ +@import("../render/cpu_raster.zag") + +fn preview_fx(value: i64) i64 { return value * unit_scale(); } + +fn preview_x(value: i64, width: i32) i64 { + return (value * (width as i64) * unit_scale()) / 1120; +} + +fn preview_y(value: i64, height: i32) i64 { + return (value * (height as i64) * unit_scale()) / 720; +} + +fn preview_push_rect(list: *DisplayList, id: i64, bounds: Rect, + paint: Paint) i32 { + let op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(id)); + op.bounds = bounds; + op.paint = paint; + return (display_list_push(list, op) == DisplayError.none) as i32; +} + +fn preview_push_stroke(list: *DisplayList, id: i64, bounds: Rect, + paint: Paint, width: i64) i32 { + let op: DisplayOp = display_op(DisplayOpKind.stroke_rect, node_key(id)); + op.bounds = bounds; + op.paint = paint; + op.paint.stroke_width = width; + return (display_list_push(list, op) == DisplayError.none) as i32; +} + +fn preview_add_path(list: *DisplayList, id: i64, path: *PathData, + paint: Paint) i32 { + if (path_seal(path) != PathError.none) { path_free(path); return 0; } + let encoded: PathEncodeResult = path_encode(path.*); + path_free(path); + let resource_error: RenderResourceError = display_list_add_resource(list, + render_resource_spec(id, RenderResourceKind.path, + encoded.bytes.data[0..encoded.bytes.len], path_resource_format_tag()) + ); + path_encode_free(&encoded); + if (resource_error != RenderResourceError.none) { return 0; } + let op: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(id)); + op.resource_id = id; + op.paint = paint; + return (display_list_push(list, op) == DisplayError.none) as i32; +} + +fn preview_round_rect(list: *DisplayList, id: i64, x: i64, y: i64, + width: i64, height: i64, radius: i64, paint: Paint) i32 { + let r: i64 = radius; + if (r * 2 > width) { r = width / 2; } + if (r * 2 > height) { r = height / 2; } + let op: DisplayOp = display_op(DisplayOpKind.fill_rounded_rect, node_key(id)); + op.bounds = rect(x, y, width, height); + op.parameter = r; + op.paint = paint; + return (display_list_push(list, op) == DisplayError.none) as i32; +} + +fn preview_circle(list: *DisplayList, id: i64, center_x: i64, + center_y: i64, radius: i64, paint: Paint) i32 { + return preview_round_rect(list, id, center_x - radius, center_y - radius, + radius * 2, radius * 2, radius, paint); +} + +fn preview_glass_panel(list: *DisplayList, id: i64, x: i64, y: i64, + width: i64, height: i64, radius: i64) void { + let spread: i64 = 5 * unit_scale(); + _ = preview_round_rect(list, id, x - spread, y + spread, + width + spread * 2, height + spread * 2, radius + spread, + paint_rgba16(0, 0, 0, 9000)); + _ = preview_round_rect(list, id + 1, x, y, width, height, radius, + paint_rgba16(7200, 10500, 19000, 50000)); + _ = preview_round_rect(list, id + 2, x + unit_scale(), y + unit_scale(), + width - 2 * unit_scale(), 2 * unit_scale(), radius, + paint_rgba16(42000, 56000, 65535, 17000)); +} + +fn linux_preview_scene(width: i32, height: i32) DisplayList { + let list: DisplayList = display_list_make(); + let full: Rect = rect(0, 0, (width as i64) * unit_scale(), + (height as i64) * unit_scale()); + _ = preview_push_rect(&list, 1, full, + paint_rgba16(2200, 3100, 6500, 65535)); + + // A deterministic deep-space gradient made of retained bands. It is not a + // shader approximation, so the CPU oracle remains the exact visual truth. + let band: i32 = 0; + while (band < 48) { + let y0: i64 = ((height as i64) * (band as i64)) / 48; + let y1: i64 = ((height as i64) * ((band + 1) as i64)) / 48; + let red: i64 = 2400 + (band as i64) * 45; + let green: i64 = 3500 + (band as i64) * 70; + let blue: i64 = 7600 + (band as i64) * 125; + _ = preview_push_rect(&list, 100 + band as i64, + rect(0, y0 * unit_scale(), (width as i64) * unit_scale(), + (y1 - y0 + 1) * unit_scale()), + paint_rgba16(red, green, blue, 65535)); + band = band + 1; + } + + let glow_step: i32 = 0; + while (glow_step < 9) { + let radius: i64 = preview_x(210 - (glow_step as i64) * 17, width); + _ = preview_circle(&list, 300 + glow_step as i64, + preview_x(850, width), preview_y(122, height), radius, + paint_rgba16(13000, 39000, 65535, + 1700 + (glow_step as i64) * 900)); + glow_step = glow_step + 1; + } + let warm_step: i32 = 0; + while (warm_step < 7) { + let radius: i64 = preview_x(160 - (warm_step as i64) * 18, width); + _ = preview_circle(&list, 330 + warm_step as i64, + preview_x(1035, width), preview_y(650, height), radius, + paint_rgba16(58000, 18000, 47000, + 1200 + (warm_step as i64) * 850)); + warm_step = warm_step + 1; + } + + preview_glass_panel(&list, 400, + preview_x(24, width), preview_y(20, height), + preview_x(1072, width), preview_y(64, height), preview_x(18, width)); + preview_glass_panel(&list, 410, + preview_x(24, width), preview_y(104, height), + preview_x(212, width), preview_y(592, height), preview_x(24, width)); + preview_glass_panel(&list, 420, + preview_x(256, width), preview_y(104, height), + preview_x(548, width), preview_y(592, height), preview_x(28, width)); + preview_glass_panel(&list, 430, + preview_x(824, width), preview_y(104, height), + preview_x(272, width), preview_y(592, height), preview_x(24, width)); + + // Brand mark and quiet title rhythm. + _ = preview_circle(&list, 500, preview_x(58, width), preview_y(52, height), + preview_x(15, width), paint_rgba16(18000, 52000, 65535, 65535)); + _ = preview_circle(&list, 501, preview_x(62, width), preview_y(48, height), + preview_x(7, width), paint_rgba16(52000, 63000, 65535, 56000)); + _ = preview_round_rect(&list, 502, preview_x(88, width), preview_y(39, height), + preview_x(128, width), preview_y(12, height), preview_x(6, width), + paint_rgba16(52000, 58000, 65000, 60000)); + _ = preview_round_rect(&list, 503, preview_x(88, width), preview_y(58, height), + preview_x(76, width), preview_y(7, height), preview_x(4, width), + paint_rgba16(25000, 34000, 50000, 50000)); + _ = preview_round_rect(&list, 504, preview_x(935, width), preview_y(36, height), + preview_x(132, width), preview_y(32, height), preview_x(16, width), + paint_rgba16(12500, 38500, 61000, 58000)); + + // Left navigation: stable spacing, selected state, and aligned glyph marks. + let nav: i32 = 0; + while (nav < 6) { + let ny: i64 = preview_y(142 + (nav as i64) * 70, height); + if (nav == 1) { + _ = preview_round_rect(&list, 600 + nav as i64, + preview_x(42, width), ny - preview_y(13, height), + preview_x(176, width), preview_y(48, height), preview_x(15, width), + paint_rgba16(12500, 36000, 59000, 50000)); + } + _ = preview_round_rect(&list, 620 + nav as i64, + preview_x(58, width), ny, preview_x(20, width), preview_y(20, height), + preview_x(7, width), paint_rgba16( + 18000 + (nav as i64) * 1800, + 35000 + (nav as i64) * 1900, 56000, 52000)); + _ = preview_round_rect(&list, 640 + nav as i64, + preview_x(94, width), ny + preview_y(4, height), + preview_x(86 + ((nav % 3) as i64) * 18, width), preview_y(10, height), + preview_x(5, width), paint_rgba16(33000, 41000, 53500, 48000)); + nav = nav + 1; + } + + // Main product card with a continuous path visualization. + _ = preview_round_rect(&list, 700, preview_x(284, width), preview_y(132, height), + preview_x(492, width), preview_y(72, height), preview_x(20, width), + paint_rgba16(9000, 15500, 27500, 52000)); + _ = preview_round_rect(&list, 701, preview_x(310, width), preview_y(151, height), + preview_x(184, width), preview_y(14, height), preview_x(7, width), + paint_rgba16(52000, 57500, 64500, 60000)); + _ = preview_round_rect(&list, 702, preview_x(310, width), preview_y(175, height), + preview_x(116, width), preview_y(8, height), preview_x(4, width), + paint_rgba16(24500, 33000, 47500, 50000)); + _ = preview_round_rect(&list, 703, preview_x(650, width), preview_y(148, height), + preview_x(98, width), preview_y(34, height), preview_x(17, width), + paint_rgba16(17000, 48500, 65535, 58000)); + + _ = preview_round_rect(&list, 710, preview_x(284, width), preview_y(224, height), + preview_x(492, width), preview_y(282, height), preview_x(22, width), + paint_rgba16(4500, 7600, 14500, 48000)); + let grid_line: i32 = 0; + while (grid_line < 8) { + _ = preview_push_rect(&list, 720 + grid_line as i64, + rect(preview_x(310 + (grid_line as i64) * 60, width), preview_y(246, height), + preview_x(1, width), preview_y(236, height)), + paint_rgba16(14000, 22000, 36000, 13000)); + grid_line = grid_line + 1; + } + let horizontal: i32 = 0; + while (horizontal < 5) { + _ = preview_push_rect(&list, 740 + horizontal as i64, + rect(preview_x(308, width), preview_y(258 + (horizontal as i64) * 48, height), + preview_x(442, width), preview_y(1, height)), + paint_rgba16(14000, 22000, 36000, 13000)); + horizontal = horizontal + 1; + } + let curve_point: i32 = 0; + while (curve_point < 25) { + let px: i64 = 320 + (curve_point as i64) * 17; + let phase: i64 = curve_point % 8; + let py: i64 = 390 - phase * 12 + ((curve_point / 8) as i64) * 15; + _ = preview_circle(&list, 760 + curve_point as i64, + preview_x(px, width), preview_y(py, height), preview_x(5, width), + paint_rgba16(12000, 47000, 65535, 47000)); + curve_point = curve_point + 1; + } + + // Bottom action strip and right-side activity cards. + let chip: i32 = 0; + while (chip < 4) { + _ = preview_round_rect(&list, 800 + chip as i64, + preview_x(284 + (chip as i64) * 124, width), preview_y(530, height), + preview_x(108, width), preview_y(42, height), preview_x(16, width), + paint_rgba16(9000 + (chip as i64) * 2200, + 18000 + (chip as i64) * 2500, 34000 + (chip as i64) * 3200, 50000)); + chip = chip + 1; + } + let activity: i32 = 0; + while (activity < 5) { + let ay: i64 = 138 + (activity as i64) * 96; + _ = preview_round_rect(&list, 840 + activity as i64, + preview_x(848, width), preview_y(ay, height), + preview_x(224, width), preview_y(74, height), preview_x(18, width), + paint_rgba16(7000, 12000, 22500, 47000)); + _ = preview_circle(&list, 860 + activity as i64, + preview_x(876, width), preview_y(ay + 27, height), preview_x(11, width), + paint_rgba16(15000 + (activity as i64) * 6000, + 51000 - (activity as i64) * 3500, 62000, 56000)); + _ = preview_round_rect(&list, 880 + activity as i64, + preview_x(900, width), preview_y(ay + 17, height), + preview_x(126, width), preview_y(10, height), preview_x(5, width), + paint_rgba16(41000, 48500, 59000, 50000)); + _ = preview_round_rect(&list, 900 + activity as i64, + preview_x(900, width), preview_y(ay + 38, height), + preview_x(88, width), preview_y(7, height), preview_x(4, width), + paint_rgba16(22000, 30000, 44000, 43000)); + activity = activity + 1; + } + + _ = preview_push_stroke(&list, 950, + rect(preview_x(24, width), preview_y(20, height), + preview_x(1072, width), preview_y(64, height)), + paint_rgba16(32000, 47000, 65535, 15000), unit_scale()); + _ = display_list_seal(&list); + return list; +} diff --git a/src/text/opentype.zag b/src/text/opentype.zag new file mode 100644 index 0000000..b4363a1 --- /dev/null +++ b/src/text/opentype.zag @@ -0,0 +1,309 @@ +@import("std:list") + +// Bounded, owned SFNT/OpenType parsing. This is the first font-engine seam: +// callers receive validated metrics and deterministic Unicode-to-glyph lookup +// without a runtime dependency on FreeType, HarfBuzz, Skia, or an OS text API. + +enum OpenTypeError { + none, + empty, + too_large, + truncated, + unsupported_signature, + invalid_table_count, + invalid_table_bounds, + duplicate_required_table, + missing_cmap, + missing_head, + missing_maxp, + invalid_metrics, + unsupported_cmap, + invalid_cmap, + invalid_codepoint, + invalid_glyph, +} + +struct OpenTypeFace { + bytes: ArrayList[u8], + units_per_em: i32, + glyph_count: i32, + cmap_offset: i32, + cmap_length: i32, + cmap_format: i32, + error: OpenTypeError, + error_offset: i64, +} + +struct GlyphLookup { + glyph_id: i32, + error: OpenTypeError, +} + +fn opentype_u16(bytes: []u8, offset: i32) i32 { + return ((bytes[offset] as i32) << 8) | (bytes[offset + 1] as i32); +} + +fn opentype_u32(bytes: []u8, offset: i32) i64 { + return ((bytes[offset] as i64) << 24) | + ((bytes[offset + 1] as i64) << 16) | + ((bytes[offset + 2] as i64) << 8) | + (bytes[offset + 3] as i64); +} + +fn opentype_face_empty() OpenTypeFace { + return OpenTypeFace{ + .bytes = make[u8](1), + .units_per_em = 0, + .glyph_count = 0, + .cmap_offset = 0, + .cmap_length = 0, + .cmap_format = 0, + .error = OpenTypeError.none, + .error_offset = 0, + }; +} + +fn opentype_fail(face: *OpenTypeFace, error: OpenTypeError, offset: i64) void { + face.*.error = error; + face.*.error_offset = offset; +} + +fn opentype_tag(bytes: []u8, offset: i32, a: i32, b: i32, + c: i32, d: i32) i32 { + return (bytes[offset] as i32 == a && bytes[offset + 1] as i32 == b && + bytes[offset + 2] as i32 == c && bytes[offset + 3] as i32 == d) as i32; +} + +fn opentype_signature_supported(bytes: []u8) i32 { + let signature: i64 = opentype_u32(bytes, 0); + if (signature == 65536) { return 1; } + return (opentype_tag(bytes, 0, 79, 84, 84, 79) != 0 || + opentype_tag(bytes, 0, 116, 114, 117, 101) != 0) as i32; +} + +fn opentype_table_valid(file_length: i32, offset: i64, length: i64) i32 { + if (offset < 0 || length < 0 || offset > file_length as i64) { return 0; } + if (length > (file_length as i64) - offset) { return 0; } + return 1; +} + +fn opentype_parse(data: []u8) OpenTypeFace { + let face: OpenTypeFace = opentype_face_empty(); + if (data.len == 0) { opentype_fail(&face, OpenTypeError.empty, 0); return face; } + if (data.len > 67108864) { + opentype_fail(&face, OpenTypeError.too_large, data.len as i64); return face; + } + let copy_index: i32 = 0; + while (copy_index < data.len) { + push[u8](&face.bytes, data[copy_index]); + copy_index = copy_index + 1; + } + let bytes: []u8 = face.bytes.data[0..face.bytes.len]; + if (bytes.len < 12) { opentype_fail(&face, OpenTypeError.truncated, bytes.len as i64); return face; } + if (opentype_signature_supported(bytes) == 0) { + opentype_fail(&face, OpenTypeError.unsupported_signature, 0); return face; + } + let table_count: i32 = opentype_u16(bytes, 4); + if (table_count <= 0 || table_count > 4096) { + opentype_fail(&face, OpenTypeError.invalid_table_count, 4); return face; + } + let directory_bytes: i64 = 12 + (table_count as i64) * 16; + if (directory_bytes > bytes.len as i64) { + opentype_fail(&face, OpenTypeError.truncated, bytes.len as i64); return face; + } + let cmap_table_offset: i32 = 0; + let cmap_table_length: i32 = 0; + let head_offset: i32 = 0; + let head_length: i32 = 0; + let maxp_offset: i32 = 0; + let maxp_length: i32 = 0; + let table_index: i32 = 0; + while (table_index < table_count) { + let record: i32 = 12 + table_index * 16; + let offset64: i64 = opentype_u32(bytes, record + 8); + let length64: i64 = opentype_u32(bytes, record + 12); + if (opentype_table_valid(bytes.len, offset64, length64) == 0) { + opentype_fail(&face, OpenTypeError.invalid_table_bounds, record + 8); + return face; + } + if (opentype_tag(bytes, record, 99, 109, 97, 112) != 0) { + if (cmap_table_length != 0) { + opentype_fail(&face, OpenTypeError.duplicate_required_table, record); + return face; + } + cmap_table_offset = offset64 as i32; cmap_table_length = length64 as i32; + } else if (opentype_tag(bytes, record, 104, 101, 97, 100) != 0) { + if (head_length != 0) { + opentype_fail(&face, OpenTypeError.duplicate_required_table, record); + return face; + } + head_offset = offset64 as i32; head_length = length64 as i32; + } else if (opentype_tag(bytes, record, 109, 97, 120, 112) != 0) { + if (maxp_length != 0) { + opentype_fail(&face, OpenTypeError.duplicate_required_table, record); + return face; + } + maxp_offset = offset64 as i32; maxp_length = length64 as i32; + } + table_index = table_index + 1; + } + if (cmap_table_length == 0) { opentype_fail(&face, OpenTypeError.missing_cmap, 12); return face; } + if (head_length == 0) { opentype_fail(&face, OpenTypeError.missing_head, 12); return face; } + if (maxp_length == 0) { opentype_fail(&face, OpenTypeError.missing_maxp, 12); return face; } + if (head_length < 20 || maxp_length < 6 || cmap_table_length < 4) { + opentype_fail(&face, OpenTypeError.invalid_metrics, 12); return face; + } + face.units_per_em = opentype_u16(bytes, head_offset + 18); + face.glyph_count = opentype_u16(bytes, maxp_offset + 4); + if (face.units_per_em < 16 || face.units_per_em > 16384 || face.glyph_count <= 0) { + opentype_fail(&face, OpenTypeError.invalid_metrics, head_offset + 18); + return face; + } + let cmap_count: i32 = opentype_u16(bytes, cmap_table_offset + 2); + if (cmap_count <= 0 || cmap_count > 4096 || + 4 + (cmap_count as i64) * 8 > cmap_table_length as i64) { + opentype_fail(&face, OpenTypeError.invalid_cmap, cmap_table_offset + 2); + return face; + } + let best_score: i32 = 0; + let best_offset: i32 = 0; + let best_length: i32 = 0; + let best_format: i32 = 0; + let encoding_index: i32 = 0; + while (encoding_index < cmap_count) { + let record: i32 = cmap_table_offset + 4 + encoding_index * 8; + let platform: i32 = opentype_u16(bytes, record); + let encoding: i32 = opentype_u16(bytes, record + 2); + let relative64: i64 = opentype_u32(bytes, record + 4); + if (relative64 >= 0 && relative64 + 2 <= cmap_table_length as i64) { + let subtable: i32 = cmap_table_offset + relative64 as i32; + let format: i32 = opentype_u16(bytes, subtable); + let length64: i64 = 0; + if (format == 4 && relative64 + 4 <= cmap_table_length as i64) { + length64 = opentype_u16(bytes, subtable + 2) as i64; + } else if (format == 12 && relative64 + 8 <= cmap_table_length as i64) { + length64 = opentype_u32(bytes, subtable + 4); + } + let score: i32 = 0; + if (format == 12 && platform == 3 && encoding == 10) { score = 40; } + else if (format == 12 && platform == 0) { score = 35; } + else if (format == 4 && platform == 3 && (encoding == 1 || encoding == 0)) { score = 30; } + else if (format == 4 && platform == 0) { score = 25; } + if (score > best_score && length64 >= 16 && + relative64 + length64 <= cmap_table_length as i64) { + best_score = score; best_offset = subtable; + best_length = length64 as i32; best_format = format; + } + } + encoding_index = encoding_index + 1; + } + if (best_score == 0) { + opentype_fail(&face, OpenTypeError.unsupported_cmap, cmap_table_offset); + return face; + } + face.cmap_offset = best_offset; + face.cmap_length = best_length; + face.cmap_format = best_format; + return face; +} + +fn opentype_lookup_fail(error: OpenTypeError) GlyphLookup { + return GlyphLookup{ .glyph_id = 0, .error = error }; +} + +fn opentype_lookup_format12(face: OpenTypeFace, codepoint: i64) GlyphLookup { + let bytes: []u8 = face.bytes.data[0..face.bytes.len]; + if (face.cmap_length < 16) { return opentype_lookup_fail(OpenTypeError.invalid_cmap); } + let group_count64: i64 = opentype_u32(bytes, face.cmap_offset + 12); + if (group_count64 > 1048576 || 16 + group_count64 * 12 > face.cmap_length as i64) { + return opentype_lookup_fail(OpenTypeError.invalid_cmap); + } + let low: i64 = 0; + let high: i64 = group_count64; + while (low < high) { + let middle: i64 = low + (high - low) / 2; + let group: i32 = face.cmap_offset + 16 + (middle as i32) * 12; + let start: i64 = opentype_u32(bytes, group); + let end: i64 = opentype_u32(bytes, group + 4); + if (end < start) { return opentype_lookup_fail(OpenTypeError.invalid_cmap); } + if (codepoint < start) { high = middle; } + else if (codepoint > end) { low = middle + 1; } + else { + let glyph: i64 = opentype_u32(bytes, group + 8) + (codepoint - start); + if (glyph < 0 || glyph >= face.glyph_count as i64) { + return opentype_lookup_fail(OpenTypeError.invalid_glyph); + } + return GlyphLookup{ .glyph_id = glyph as i32, .error = OpenTypeError.none }; + } + } + return GlyphLookup{ .glyph_id = 0, .error = OpenTypeError.none }; +} + +fn opentype_lookup_format4(face: OpenTypeFace, codepoint: i64) GlyphLookup { + if (codepoint > 65535) { return GlyphLookup{ .glyph_id = 0, .error = OpenTypeError.none }; } + let bytes: []u8 = face.bytes.data[0..face.bytes.len]; + if (face.cmap_length < 16) { return opentype_lookup_fail(OpenTypeError.invalid_cmap); } + let segment_count: i32 = opentype_u16(bytes, face.cmap_offset + 6) / 2; + if (segment_count <= 0 || segment_count > 32768) { + return opentype_lookup_fail(OpenTypeError.invalid_cmap); + } + let end_base: i32 = face.cmap_offset + 14; + let start_base: i32 = end_base + segment_count * 2 + 2; + let delta_base: i32 = start_base + segment_count * 2; + let range_base: i32 = delta_base + segment_count * 2; + if (range_base + segment_count * 2 > face.cmap_offset + face.cmap_length) { + return opentype_lookup_fail(OpenTypeError.invalid_cmap); + } + let segment: i32 = 0; + while (segment < segment_count) { + let end: i32 = opentype_u16(bytes, end_base + segment * 2); + if (codepoint <= end as i64) { + let start: i32 = opentype_u16(bytes, start_base + segment * 2); + if (codepoint < start as i64) { + return GlyphLookup{ .glyph_id = 0, .error = OpenTypeError.none }; + } + let delta_raw: i32 = opentype_u16(bytes, delta_base + segment * 2); + let delta: i32 = delta_raw; + if (delta >= 32768) { delta = delta - 65536; } + let range: i32 = opentype_u16(bytes, range_base + segment * 2); + let glyph: i32 = 0; + if (range == 0) { + glyph = ((codepoint as i32) + delta) % 65536; + if (glyph < 0) { glyph = glyph + 65536; } + } else { + let glyph_offset: i32 = range_base + segment * 2 + range + + ((codepoint as i32) - start) * 2; + if (glyph_offset < face.cmap_offset || + glyph_offset + 2 > face.cmap_offset + face.cmap_length) { + return opentype_lookup_fail(OpenTypeError.invalid_cmap); + } + glyph = opentype_u16(bytes, glyph_offset); + if (glyph != 0) { + glyph = (glyph + delta) % 65536; + if (glyph < 0) { glyph = glyph + 65536; } + } + } + if (glyph >= face.glyph_count) { + return opentype_lookup_fail(OpenTypeError.invalid_glyph); + } + return GlyphLookup{ .glyph_id = glyph, .error = OpenTypeError.none }; + } + segment = segment + 1; + } + return GlyphLookup{ .glyph_id = 0, .error = OpenTypeError.none }; +} + +fn opentype_glyph_for_scalar(face: OpenTypeFace, codepoint: i64) GlyphLookup { + if (face.error != OpenTypeError.none) { return opentype_lookup_fail(face.error); } + if (codepoint < 0 || codepoint > 1114111 || + (codepoint >= 55296 && codepoint <= 57343)) { + return opentype_lookup_fail(OpenTypeError.invalid_codepoint); + } + if (face.cmap_format == 12) { return opentype_lookup_format12(face, codepoint); } + if (face.cmap_format == 4) { return opentype_lookup_format4(face, codepoint); } + return opentype_lookup_fail(OpenTypeError.unsupported_cmap); +} + +fn opentype_face_free(face: *OpenTypeFace) void { + free[u8](&face.*.bytes); +} diff --git a/src/text/unicode.zag b/src/text/unicode.zag new file mode 100644 index 0000000..d528fc4 --- /dev/null +++ b/src/text/unicode.zag @@ -0,0 +1,122 @@ +@import("std:list") + +// Unicode scalar decoding is owned by Zagkit. The decoder is strict: malformed +// UTF-8 never becomes silently substituted UI text, and the exact offending +// byte remains available to editing, diagnostics, fuzzing, and replay. + +enum Utf8Error { + none, + input_too_large, + truncated, + invalid_lead, + invalid_continuation, + overlong, + surrogate, + out_of_range, +} + +struct UnicodeScalar { + value: i64, + byte_start: i64, + byte_length: i32, +} + +struct Utf8DecodeResult { + scalars: ArrayList[UnicodeScalar], + error: Utf8Error, + error_offset: i64, +} + +fn unicode_scalar(value: i64, start: i64, length: i32) UnicodeScalar { + return UnicodeScalar{ + .value = value, + .byte_start = start, + .byte_length = length, + }; +} + +fn utf8_result() Utf8DecodeResult { + return Utf8DecodeResult{ + .scalars = make[UnicodeScalar](16), + .error = Utf8Error.none, + .error_offset = 0, + }; +} + +fn utf8_fail(result: *Utf8DecodeResult, error: Utf8Error, offset: i64) void { + result.*.error = error; + result.*.error_offset = offset; +} + +fn utf8_continuation(byte: i32) i32 { + return ((byte & 192) == 128) as i32; +} + +fn utf8_decode(bytes: []u8) Utf8DecodeResult { + let result: Utf8DecodeResult = utf8_result(); + if (bytes.len > 1048576) { + utf8_fail(&result, Utf8Error.input_too_large, bytes.len as i64); + return result; + } + let offset: i32 = 0; + while (offset < bytes.len) { + let lead: i32 = bytes[offset] as i32; + let length: i32 = 0; + let value: i64 = 0; + let minimum: i64 = 0; + if (lead < 128) { + length = 1; + value = lead as i64; + } else if (lead >= 194 && lead <= 223) { + length = 2; + value = (lead & 31) as i64; + minimum = 128; + } else if (lead >= 224 && lead <= 239) { + length = 3; + value = (lead & 15) as i64; + minimum = 2048; + } else if (lead >= 240 && lead <= 244) { + length = 4; + value = (lead & 7) as i64; + minimum = 65536; + } else { + utf8_fail(&result, Utf8Error.invalid_lead, offset as i64); + return result; + } + if (offset + length > bytes.len) { + utf8_fail(&result, Utf8Error.truncated, offset as i64); + return result; + } + let continuation_index: i32 = 1; + while (continuation_index < length) { + let continuation: i32 = bytes[offset + continuation_index] as i32; + if (utf8_continuation(continuation) == 0) { + utf8_fail(&result, Utf8Error.invalid_continuation, + (offset + continuation_index) as i64); + return result; + } + value = (value << 6) | ((continuation & 63) as i64); + continuation_index = continuation_index + 1; + } + if (length > 1 && value < minimum) { + utf8_fail(&result, Utf8Error.overlong, offset as i64); + return result; + } + if (value >= 55296 && value <= 57343) { + utf8_fail(&result, Utf8Error.surrogate, offset as i64); + return result; + } + if (value > 1114111) { + utf8_fail(&result, Utf8Error.out_of_range, offset as i64); + return result; + } + push[UnicodeScalar](&result.scalars, + unicode_scalar(value, offset as i64, length)); + offset = offset + length; + } + return result; +} + +fn utf8_decode_free(result: *Utf8DecodeResult) void { + free[UnicodeScalar](&result.*.scalars); +} diff --git a/tests/cpu_raster_contract.zag b/tests/cpu_raster_contract.zag index 292b041..1da0967 100644 --- a/tests/cpu_raster_contract.zag +++ b/tests/cpu_raster_contract.zag @@ -98,6 +98,24 @@ fn exact_fill_contract(state: *TestState) void { display_list_free(&list); } +fn rounded_fill_contract(state: *TestState) void { + let list: DisplayList = display_list_make(); + let rounded: DisplayOp = display_op(DisplayOpKind.fill_rounded_rect, node_key(2)); + rounded.bounds = rect(0, 0, 6 * unit_scale(), 6 * unit_scale()); + rounded.parameter = 3 * unit_scale(); + rounded.paint = paint_rgba16(0, 65535, 65535, 65535); + _ = display_list_push(&list, rounded); + _ = display_list_seal(&list); + let result: CpuRasterResult = cpu_rasterize(list, 6, 6); + expect(state, result.error == CpuRasterError.none && result.rendered_ops == 1, + "analytic rounded rectangles rasterize as an explicit CPU operation"); + expect(state, pixel(result.surface, 0, 0, 3) < 255 && + pixel(result.surface, 3, 3, 1) == 255, + "rounded corners are anti-aliased while the center remains opaque"); + cpu_raster_free(&result); + display_list_free(&list); +} + fn coverage_and_blend_contract(state: *TestState) void { let half: DisplayList = fill_scene(rect(unit_scale() / 2, 0, unit_scale(), unit_scale()), paint_rgba16(65535, 0, 0, 65535)); let edge: CpuRasterResult = cpu_rasterize(half, 2, 1); @@ -398,6 +416,7 @@ fn failure_contract(state: *TestState) void { fn main() i32 { let state: TestState = TestState{ .passed = 0, .failed = 0 }; exact_fill_contract(&state); + rounded_fill_contract(&state); coverage_and_blend_contract(&state); clip_and_transform_contract(&state); deterministic_contract(&state); diff --git a/tests/display_list_codec_contract.zag b/tests/display_list_codec_contract.zag index 29677dd..fdd5b04 100644 --- a/tests/display_list_codec_contract.zag +++ b/tests/display_list_codec_contract.zag @@ -108,7 +108,7 @@ fn malformed_contract(state: *TestState) void { encoded.bytes.data[0] = saved; let saved_version: u8 = encoded.bytes.data[4]; - encoded.bytes.data[4] = 3; + encoded.bytes.data[4] = 4; let bad_version: DisplayDecodeResult = display_list_decode(encoded.bytes); expect(state, bad_version.error == DisplayCodecError.unsupported_version && bad_version.error_offset == 4, "unsupported versions fail visibly"); display_decode_free(&bad_version); diff --git a/tests/display_list_contract.zag b/tests/display_list_contract.zag index 646cc9f..a56bd40 100644 --- a/tests/display_list_contract.zag +++ b/tests/display_list_contract.zag @@ -57,6 +57,10 @@ fn build_scene(color: i64) DisplayList { fill.bounds = rect(0 - unit_scale(), 0, 80 * unit_scale(), 40 * unit_scale()); fill.paint = paint_rgba16(color, 20000, 30000, 65535); _ = display_list_push(&list, fill); + let rounded: DisplayOp = display_op(DisplayOpKind.fill_rounded_rect, owner); + rounded.bounds = rect(unit_scale(), unit_scale(), 8 * unit_scale(), 8 * unit_scale()); + rounded.parameter = 2 * unit_scale(); + _ = display_list_push(&list, rounded); let path: DisplayOp = display_op(DisplayOpKind.draw_path, owner); path.resource_id = 101; @@ -90,11 +94,11 @@ fn scene_contract(state: *TestState) void { let first: DisplayList = build_scene(10000); let second: DisplayList = build_scene(10000); let changed: DisplayList = build_scene(10001); - expect(state, first.ops.len == 11 && first.stack_depth == 0 && first.layer_depth == 0, "display list records a balanced scene"); - expect(state, first.resources.resources.len == 4 && first.revision == 15, + expect(state, first.ops.len == 12 && first.stack_depth == 0 && first.layer_depth == 0, "display list records a balanced scene"); + expect(state, first.resources.resources.len == 4 && first.revision == 16, "display list owns every referenced resource and includes resource revisions"); - expect(state, first.ops.data[4].kind == DisplayOpKind.draw_path && first.ops.data[5].kind == DisplayOpKind.draw_image && first.ops.data[6].kind == DisplayOpKind.draw_glyph_run, "paths, images, and glyph runs remain explicit operations"); - expect(state, first.ops.data[7].kind == DisplayOpKind.begin_layer && first.ops.data[8].kind == DisplayOpKind.apply_effect, "layers and effects remain explicit operations"); + expect(state, first.ops.data[4].kind == DisplayOpKind.fill_rounded_rect && first.ops.data[5].kind == DisplayOpKind.draw_path && first.ops.data[6].kind == DisplayOpKind.draw_image && first.ops.data[7].kind == DisplayOpKind.draw_glyph_run, "rounded geometry, paths, images, and glyph runs remain explicit operations"); + expect(state, first.ops.data[8].kind == DisplayOpKind.begin_layer && first.ops.data[9].kind == DisplayOpKind.apply_effect, "layers and effects remain explicit operations"); expect(state, first.content_hash == second.content_hash, "identical scenes produce the same deterministic content hash"); expect(state, first.content_hash != changed.content_hash, "one paint-channel change alters content identity"); diff --git a/tests/opentype_contract.zag b/tests/opentype_contract.zag new file mode 100644 index 0000000..5c08682 --- /dev/null +++ b/tests/opentype_contract.zag @@ -0,0 +1,137 @@ +@import("../src/text/opentype.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn fixture_u16(bytes: *ArrayList[u8], offset: i32, value: i32) void { + bytes.*.data[offset] = ((value >> 8) & 255) as u8; + bytes.*.data[offset + 1] = (value & 255) as u8; +} + +fn fixture_u32(bytes: *ArrayList[u8], offset: i32, value: i64) void { + bytes.*.data[offset] = ((value >> 24) & 255) as u8; + bytes.*.data[offset + 1] = ((value >> 16) & 255) as u8; + bytes.*.data[offset + 2] = ((value >> 8) & 255) as u8; + bytes.*.data[offset + 3] = (value & 255) as u8; +} + +fn fixture_tag(bytes: *ArrayList[u8], offset: i32, tag: []u8) void { + let i: i32 = 0; + while (i < 4) { bytes.*.data[offset + i] = tag[i]; i = i + 1; } +} + +fn fixture_font() ArrayList[u8] { + let bytes: ArrayList[u8] = make[u8](136); + let i: i32 = 0; + while (i < 136) { push[u8](&bytes, 0); i = i + 1; } + fixture_u32(&bytes, 0, 65536); fixture_u16(&bytes, 4, 3); + fixture_tag(&bytes, 12, "cmap"); fixture_u32(&bytes, 20, 60); fixture_u32(&bytes, 24, 44); + fixture_tag(&bytes, 28, "head"); fixture_u32(&bytes, 36, 104); fixture_u32(&bytes, 40, 20); + fixture_tag(&bytes, 44, "maxp"); fixture_u32(&bytes, 52, 124); fixture_u32(&bytes, 56, 6); + fixture_u16(&bytes, 60, 0); fixture_u16(&bytes, 62, 1); + fixture_u16(&bytes, 64, 3); fixture_u16(&bytes, 66, 1); fixture_u32(&bytes, 68, 12); + fixture_u16(&bytes, 72, 4); fixture_u16(&bytes, 74, 32); + fixture_u16(&bytes, 76, 0); fixture_u16(&bytes, 78, 4); + fixture_u16(&bytes, 80, 4); fixture_u16(&bytes, 82, 1); fixture_u16(&bytes, 84, 0); + fixture_u16(&bytes, 86, 65); fixture_u16(&bytes, 88, 65535); fixture_u16(&bytes, 90, 0); + fixture_u16(&bytes, 92, 65); fixture_u16(&bytes, 94, 65535); + fixture_u16(&bytes, 96, 65474); fixture_u16(&bytes, 98, 1); + fixture_u16(&bytes, 100, 0); fixture_u16(&bytes, 102, 0); + fixture_u16(&bytes, 122, 1000); + fixture_u32(&bytes, 124, 65536); fixture_u16(&bytes, 128, 10); + return bytes; +} + +fn fixture_font_format12() ArrayList[u8] { + let bytes: ArrayList[u8] = fixture_font(); + fixture_u16(&bytes, 66, 10); + fixture_u16(&bytes, 72, 12); fixture_u16(&bytes, 74, 0); + fixture_u32(&bytes, 76, 28); fixture_u32(&bytes, 80, 0); + fixture_u32(&bytes, 84, 1); + fixture_u32(&bytes, 88, 128512); fixture_u32(&bytes, 92, 128512); + fixture_u32(&bytes, 96, 4); + return bytes; +} + +fn parse_contract(state: *TestState) void { + let bytes: ArrayList[u8] = fixture_font(); + let face: OpenTypeFace = opentype_parse(bytes.data[0..bytes.len]); + expect(state, face.error == OpenTypeError.none, + "bounded SFNT directory and required tables parse"); + expect(state, face.units_per_em == 1000 && face.glyph_count == 10 && + face.cmap_format == 4, + "font metrics and selected cmap format remain explicit"); + let mapped: GlyphLookup = opentype_glyph_for_scalar(face, 65); + let missing: GlyphLookup = opentype_glyph_for_scalar(face, 66); + expect(state, mapped.error == OpenTypeError.none && mapped.glyph_id == 3, + "format 4 maps a Unicode scalar to the exact glyph ID"); + expect(state, missing.error == OpenTypeError.none && missing.glyph_id == 0, + "missing scalars resolve to glyph zero without fabricated fallback"); + let invalid: GlyphLookup = opentype_glyph_for_scalar(face, 55296); + expect(state, invalid.error == OpenTypeError.invalid_codepoint, + "surrogate lookup fails before cmap access"); + opentype_face_free(&face); free[u8](&bytes); + + let format12_bytes: ArrayList[u8] = fixture_font_format12(); + let format12_face: OpenTypeFace = opentype_parse( + format12_bytes.data[0..format12_bytes.len]); + let emoji: GlyphLookup = opentype_glyph_for_scalar(format12_face, 128512); + expect(state, format12_face.error == OpenTypeError.none && + format12_face.cmap_format == 12 && emoji.error == OpenTypeError.none && + emoji.glyph_id == 4, + "format 12 maps supplementary-plane Unicode scalars"); + opentype_face_free(&format12_face); free[u8](&format12_bytes); +} + +fn malformed_contract(state: *TestState) void { + let short_face: OpenTypeFace = opentype_parse("font"); + expect(state, short_face.error == OpenTypeError.truncated, + "truncated SFNT headers fail with owned cleanup"); + opentype_face_free(&short_face); + + let bad_signature_bytes: ArrayList[u8] = fixture_font(); + bad_signature_bytes.data[1] = 0; + let bad_signature: OpenTypeFace = opentype_parse( + bad_signature_bytes.data[0..bad_signature_bytes.len]); + expect(state, bad_signature.error == OpenTypeError.unsupported_signature, + "unknown SFNT signatures fail closed"); + opentype_face_free(&bad_signature); free[u8](&bad_signature_bytes); + + let bad_bounds_bytes: ArrayList[u8] = fixture_font(); + fixture_u32(&bad_bounds_bytes, 20, 1000); + let bad_bounds: OpenTypeFace = opentype_parse( + bad_bounds_bytes.data[0..bad_bounds_bytes.len]); + expect(state, bad_bounds.error == OpenTypeError.invalid_table_bounds && + bad_bounds.error_offset == 20, + "table offsets cannot escape owned font bytes"); + opentype_face_free(&bad_bounds); free[u8](&bad_bounds_bytes); + + let duplicate_bytes: ArrayList[u8] = fixture_font(); + fixture_tag(&duplicate_bytes, 28, "cmap"); + let duplicate: OpenTypeFace = opentype_parse( + duplicate_bytes.data[0..duplicate_bytes.len]); + expect(state, duplicate.error == OpenTypeError.duplicate_required_table, + "duplicate required tables fail before ambiguous selection"); + opentype_face_free(&duplicate); free[u8](&duplicate_bytes); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + parse_contract(&state); malformed_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("OpenType contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tests/platform_capabilities_contract.zag b/tests/platform_capabilities_contract.zag new file mode 100644 index 0000000..04755a9 --- /dev/null +++ b/tests/platform_capabilities_contract.zag @@ -0,0 +1,38 @@ +@import("../src/platform/capabilities.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: bool, name: []u8) void { + if (condition) { state.*.passed = state.*.passed + 1; } + else { state.*.failed = state.*.failed + 1; _zag_eprintln(name); } +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + let x11: PlatformCapabilities = platform_capabilities_linux_auto(0, 1); + expect(&state, x11.active == PlatformBackend.x11 && + x11.window_surface.status == PlatformSupport.experimental, + "X11 activation is explicit and experimental"); + expect(&state, x11.fallback_recorded == 0, + "direct X11 activation does not invent a fallback event"); + + let fallback: PlatformCapabilities = platform_capabilities_linux_auto(1, 1); + expect(&state, fallback.active == PlatformBackend.x11 && + fallback.fallback_recorded == 1 && fallback.fallback_reason.len > 0, + "Wayland-to-X11 fallback is observable"); + + let unavailable: PlatformCapabilities = platform_capabilities_linux_auto(1, 0); + expect(&state, unavailable.active == PlatformBackend.headless && + unavailable.window_surface.status == PlatformSupport.unavailable && + unavailable.fallback_recorded == 1, + "missing native shells fail closed to reported headless operation"); + + let headless: PlatformCapabilities = platform_capabilities_headless(); + expect(&state, headless.cpu_presentation.status == PlatformSupport.supported && + headless.accessibility.status == PlatformSupport.unavailable, + "headless CPU support does not imply accessibility support"); + + if (state.failed != 0) { return 1; } + _zag_println("Platform capabilities contract: pass=5 fail=0"); + return 0; +} diff --git a/tests/unicode_contract.zag b/tests/unicode_contract.zag new file mode 100644 index 0000000..e54f09e --- /dev/null +++ b/tests/unicode_contract.zag @@ -0,0 +1,106 @@ +@import("../src/text/unicode.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn valid_contract(state: *TestState) void { + let ascii: Utf8DecodeResult = utf8_decode("Zagkit"); + expect(state, ascii.error == Utf8Error.none && ascii.scalars.len == 6, + "ASCII decodes to one scalar per byte"); + expect(state, ascii.scalars.data[0].value == 90 && + ascii.scalars.data[5].byte_start == 5, + "scalar values retain exact source byte offsets"); + utf8_decode_free(&ascii); + + let international: Utf8DecodeResult = utf8_decode("é中😀"); + expect(state, international.error == Utf8Error.none && + international.scalars.len == 3, + "two three and four byte UTF-8 sequences decode canonically"); + expect(state, international.scalars.data[0].value == 233 && + international.scalars.data[0].byte_length == 2 && + international.scalars.data[1].value == 20013 && + international.scalars.data[1].byte_start == 2 && + international.scalars.data[2].value == 128512 && + international.scalars.data[2].byte_start == 5, + "international scalars preserve exact codepoints and byte spans"); + utf8_decode_free(&international); + + let noncharacter_bytes: ArrayList[u8] = make[u8](3); + push[u8](&noncharacter_bytes, 239); push[u8](&noncharacter_bytes, 183); + push[u8](&noncharacter_bytes, 144); + let noncharacter: Utf8DecodeResult = utf8_decode( + noncharacter_bytes.data[0..noncharacter_bytes.len]); + expect(state, noncharacter.error == Utf8Error.none && + noncharacter.scalars.data[0].value == 64976, + "Unicode noncharacters remain valid scalar values"); + utf8_decode_free(&noncharacter); + free[u8](&noncharacter_bytes); +} + +fn invalid_contract(state: *TestState) void { + let continuation: ArrayList[u8] = make[u8](2); + push[u8](&continuation, 194); push[u8](&continuation, 65); + let bad_continuation: Utf8DecodeResult = utf8_decode( + continuation.data[0..continuation.len]); + expect(state, bad_continuation.error == Utf8Error.invalid_continuation && + bad_continuation.error_offset == 1, + "invalid continuation reports the exact byte"); + utf8_decode_free(&bad_continuation); free[u8](&continuation); + + let truncated_bytes: ArrayList[u8] = make[u8](2); + push[u8](&truncated_bytes, 240); push[u8](&truncated_bytes, 159); + let truncated: Utf8DecodeResult = utf8_decode( + truncated_bytes.data[0..truncated_bytes.len]); + expect(state, truncated.error == Utf8Error.truncated && + truncated.error_offset == 0, + "truncated sequences fail at their lead byte"); + utf8_decode_free(&truncated); free[u8](&truncated_bytes); + + let overlong_bytes: ArrayList[u8] = make[u8](3); + push[u8](&overlong_bytes, 224); push[u8](&overlong_bytes, 128); + push[u8](&overlong_bytes, 128); + let overlong: Utf8DecodeResult = utf8_decode( + overlong_bytes.data[0..overlong_bytes.len]); + expect(state, overlong.error == Utf8Error.overlong, + "overlong encodings fail instead of aliasing a scalar"); + utf8_decode_free(&overlong); free[u8](&overlong_bytes); + + let surrogate_bytes: ArrayList[u8] = make[u8](3); + push[u8](&surrogate_bytes, 237); push[u8](&surrogate_bytes, 160); + push[u8](&surrogate_bytes, 128); + let surrogate: Utf8DecodeResult = utf8_decode( + surrogate_bytes.data[0..surrogate_bytes.len]); + expect(state, surrogate.error == Utf8Error.surrogate, + "UTF-16 surrogate codepoints are not Unicode scalars"); + utf8_decode_free(&surrogate); free[u8](&surrogate_bytes); + + let range_bytes: ArrayList[u8] = make[u8](4); + push[u8](&range_bytes, 244); push[u8](&range_bytes, 144); + push[u8](&range_bytes, 128); push[u8](&range_bytes, 128); + let range: Utf8DecodeResult = utf8_decode(range_bytes.data[0..range_bytes.len]); + expect(state, range.error == Utf8Error.out_of_range, + "codepoints above U plus 10FFFF fail closed"); + utf8_decode_free(&range); free[u8](&range_bytes); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + valid_contract(&state); + invalid_contract(&state); + let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); + let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Unicode contract: pass="); _zag_print(passed_text); + _zag_print(" fail="); _zag_println(failed_text); + _zag_str_free(passed_text); _zag_str_free(failed_text); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/check-contracts.sh b/tools/check-contracts.sh index 094e888..5e86a69 100755 --- a/tools/check-contracts.sh +++ b/tools/check-contracts.sh @@ -127,7 +127,12 @@ jq -e ' ) ) and (.one_point_zero.blocked == true) and - all(.platforms[].capabilities[]; .state == "unavailable") + all(.platforms[].capabilities[]; .state != "supported") and + ([(.platforms[] | select(.id == "linux") | .capabilities[] | + select(.state == "experimental") | .id)] | sort) == + ["auto_backend_selection", "cpu_renderer", "platform_shell"] and + all(.platforms[] | select(.id != "linux"); + all(.capabilities[]; .state == "unavailable")) ' contracts/platforms.json >/dev/null || fail "platform capability truth is incomplete or inflated" ledger_commit=$(jq -r '.audited_commit' contracts/upstream-zag.json) diff --git a/tools/render-linux-preview-reference.zag b/tools/render-linux-preview-reference.zag new file mode 100644 index 0000000..f678e9b --- /dev/null +++ b/tools/render-linux-preview-reference.zag @@ -0,0 +1,38 @@ +@import("../src/showcase/linux_preview_scene.zag") +@import("../src/render/png_encode.zag") + +fn main() i32 { + if (_zag_argc() != 2) { + _zag_eprintln("usage: render-linux-preview-reference "); + return 2; + } + let list: DisplayList = linux_preview_scene(1120, 720); + if (display_list_verify(list) != DisplayError.none) { + display_list_free(&list); + return 3; + } + let raster: CpuRasterResult = cpu_rasterize(list, 1120, 720); + if (raster.error != CpuRasterError.none) { + _zag_eprintln("linux preview raster failed"); + _zag_print("error="); print_i64(raster.error as i64); + _zag_print(" op="); print_i64(raster.error_op as i64); + _zag_println(""); + cpu_raster_free(&raster); + display_list_free(&list); + return 4; + } + let png: PngEncodeResult = png_encode_surface(raster.surface); + if (png.error != PngEncodeError.none) { + png_encode_free(&png); + cpu_raster_free(&raster); + display_list_free(&list); + return 5; + } + let write_error: i32 = _zag_write_file(_zag_arg(1), + png.bytes.data[0..png.bytes.len]); + png_encode_free(&png); + cpu_raster_free(&raster); + display_list_free(&list); + if (write_error != 0) { return 6; } + return 0; +} diff --git a/tools/run-linux-preview.sh b/tools/run-linux-preview.sh new file mode 100755 index 0000000..f4c2c1d --- /dev/null +++ b/tools/run-linux-preview.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +ZAG_BIN=${ZAG_BIN:-${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc}} +BUILD_DIR="$ROOT_DIR/.zagkit" +PREVIEW_BINARY="$BUILD_DIR/linux-preview" + +if ! [ -x "$ZAG_BIN" ]; then + printf 'zagkit linux preview: missing compiler executable: %s\n' "$ZAG_BIN" >&2 + exit 2 +fi +if ! ldconfig -p 2>/dev/null | grep -F 'libX11.so.6' >/dev/null; then + printf 'zagkit linux preview: libX11.so.6 is unavailable\n' >&2 + exit 2 +fi + +mkdir -p "$BUILD_DIR" +printf 'zagkit: building native Linux X11 fallback preview\n' +"$ZAG_BIN" "$ROOT_DIR/examples/linux_preview.zag" --dynamic \ + --needed libX11.so.6 --no-zagd --analyze-strict --no-foreground-cache \ + -o "$PREVIEW_BINARY" +printf 'zagkit: launching native Linux preview (%s)\n' "$PREVIEW_BINARY" +exec "$PREVIEW_BINARY" "$@" diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 051e5f4..dcfdfc6 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -14,6 +14,12 @@ cleanup() { rm -rf "$tmp"; } trap cleanup EXIT cd "$root" +"$znc" tests/platform_capabilities_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/platform-capabilities-contract" +"$tmp/platform-capabilities-contract" +"$znc" tests/unicode_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/unicode-contract" +"$tmp/unicode-contract" +"$znc" tests/opentype_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/opentype-contract" +"$tmp/opentype-contract" "$znc" tests/flex_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/flex-contract" "$tmp/flex-contract" "$znc" tests/flex_adaptive_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/flex-adaptive-contract" @@ -55,6 +61,12 @@ cd "$root" [ "$(od -An -tx1 -N8 "$tmp/reference-first.png" | tr -d ' \n')" = "89504e470d0a1a0a" ] cmp -s "$tmp/reference-first.png" "$tmp/reference-second.png" printf 'Reference snapshot contract: pass=3 fail=0\n' +"$znc" tools/render-linux-preview-reference.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/render-linux-preview-reference" +"$tmp/render-linux-preview-reference" "$tmp/linux-preview-first.png" +"$tmp/render-linux-preview-reference" "$tmp/linux-preview-second.png" +[ "$(od -An -tx1 -N8 "$tmp/linux-preview-first.png" | tr -d ' \n')" = "89504e470d0a1a0a" ] +cmp -s "$tmp/linux-preview-first.png" "$tmp/linux-preview-second.png" +printf 'Linux preview CPU snapshot contract: pass=2 fail=0\n' "$znc" tests/display_list_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/display-list-contract" "$tmp/display-list-contract" "$znc" tests/display_list_codec_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/display-list-codec-contract" @@ -68,4 +80,4 @@ printf 'Reference snapshot contract: pass=3 fail=0\n' "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths and images, bounded PNG decode, display lists, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion)\n' +printf 'headless test: PASS (backend truth, state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths and images, bounded PNG decode, display lists, analytic rounded geometry, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion)\n' diff --git a/tools/test-linux-preview.sh b/tools/test-linux-preview.sh new file mode 100755 index 0000000..e450d05 --- /dev/null +++ b/tools/test-linux-preview.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +TMP_ROOT=$(mktemp -d /tmp/zagkit-linux-preview.XXXXXX) +CAPTURE="$TMP_ROOT/linux-preview.png" + +cleanup() { rm -rf "$TMP_ROOT"; } +trap cleanup EXIT + +if [ -z "${DISPLAY:-}" ]; then + printf 'linux preview test: SKIP: DISPLAY is unavailable\n' + exit 77 +fi + +"$ROOT_DIR/zagkit" run --linux-preview --output "$CAPTURE" +if [ ! -s "$CAPTURE" ]; then + printf 'linux preview test: FAIL: native capture was not written\n' >&2 + exit 1 +fi +if [ "$(od -An -tx1 -N8 "$CAPTURE" | tr -d ' \n')" != "89504e470d0a1a0a" ]; then + printf 'linux preview test: FAIL: capture is not a PNG\n' >&2 + exit 1 +fi +dimensions=$(file "$CAPTURE") +case "$dimensions" in + *"1120 x 720"*) ;; + *) printf 'linux preview test: FAIL: unexpected capture: %s\n' "$dimensions" >&2; exit 1 ;; +esac +printf 'linux preview test: PASS (native X11 create, CPU present, sync, capture, cleanup)\n' diff --git a/tools/test-system-font.sh b/tools/test-system-font.sh new file mode 100755 index 0000000..1339bc3 --- /dev/null +++ b/tools/test-system-font.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +znc=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} +font=${1:-} + +if [ -z "$font" ] && command -v fc-match >/dev/null 2>&1; then + font=$(fc-match -f '%{file}\n' sans | head -n 1) +fi +if [ -z "$font" ] || [ ! -f "$font" ]; then + printf 'system font smoke: SKIP: no system sans font was resolved\n' + exit 77 +fi + +tmp=$(mktemp -d /tmp/zagkit-system-font.XXXXXX) +cleanup() { rm -rf "$tmp"; } +trap cleanup EXIT + +"$znc" "$root/tools/test-system-font.zag" --no-zagd --analyze-strict \ + --no-foreground-cache -o "$tmp/test-system-font" +"$tmp/test-system-font" "$font" +printf 'system font smoke: PASS (%s)\n' "$font" diff --git a/tools/test-system-font.zag b/tools/test-system-font.zag new file mode 100644 index 0000000..26eeaa1 --- /dev/null +++ b/tools/test-system-font.zag @@ -0,0 +1,47 @@ +@import("../src/text/unicode.zag") +@import("../src/text/opentype.zag") + +fn main() i32 { + if (_zag_argc() != 2) { + _zag_eprintln("usage: test-system-font "); + return 2; + } + let bytes: []u8 = _zag_read_file(_zag_arg(1)); + if (bytes.len <= 0) { + if (bytes.len >= 0) { _zag_free(_zag_slice_ptr(bytes)); } + _zag_eprintln("system font smoke: font bytes unavailable"); + return 3; + } + let face: OpenTypeFace = opentype_parse(bytes); + _zag_free(_zag_slice_ptr(bytes)); + if (face.error != OpenTypeError.none) { + opentype_face_free(&face); + _zag_eprintln("system font smoke: OpenType parse failed"); + return 4; + } + let text: Utf8DecodeResult = utf8_decode("Zagkit"); + if (text.error != Utf8Error.none) { + utf8_decode_free(&text); opentype_face_free(&face); + return 5; + } + let index: i32 = 0; + while (index < text.scalars.len) { + let glyph: GlyphLookup = opentype_glyph_for_scalar( + face, text.scalars.data[index].value); + if (glyph.error != OpenTypeError.none || glyph.glyph_id == 0) { + utf8_decode_free(&text); opentype_face_free(&face); + _zag_eprintln("system font smoke: required Latin glyph is unavailable"); + return 6; + } + index = index + 1; + } + _zag_print("system font smoke: unitsPerEm="); + _zag_print_i64(face.units_per_em as i64); + _zag_print(" glyphs="); + _zag_print_i64(face.glyph_count as i64); + _zag_print(" mapped_scalars="); + _zag_print_i64(text.scalars.len as i64); + _zag_println(""); + utf8_decode_free(&text); opentype_face_free(&face); + return 0; +} diff --git a/zagkit b/zagkit index 2b4ce68..0e3f999 100755 --- a/zagkit +++ b/zagkit @@ -15,12 +15,12 @@ usage() { Usage: ./zagkit init [path] ./zagkit build [--path ] [--output ] - ./zagkit run [--project ] [--binary ] [--output ] [--headless-only] [--show-ascii] [--open] + ./zagkit run [--linux-preview] [--project ] [--binary ] [--output ] [--headless-only] [--show-ascii] [--open] ./zagkit test -Current status: this command only exposes the headless reference and contract -test surface. It does not launch a native desktop, Wayland, macOS, -Windows, iOS, or Android shell yet. +Current status: this command exposes the deterministic headless reference and +an experimental native Linux X11 fallback preview. Wayland, accessibility, +GPU presentation, macOS, Windows, iOS, and Android remain unavailable. Automation naming note: native automation is called "Zagkit Talkback" and is distinct from Android TalkBack. This distinction is documented in @@ -182,8 +182,10 @@ cmd_run() { local project_root="" local explicit_binary=0 local headless_only=1 + local linux_preview=0 local show_ascii=0 local open_output=0 + local explicit_output=0 local source_file="" local run_source_file="$HEADLESS_REFERENCE" local compile_reference=1 @@ -201,9 +203,12 @@ cmd_run() { binary="$1"; shift;; --output) [ $# -ge 1 ] || { printf "zagkit run: --output needs a path\n" >&2; exit 2; } - output="$1"; shift;; + output="$1"; explicit_output=1; shift;; --headless-only) headless_only=1;; + --linux-preview) + linux_preview=1 + headless_only=0;; --show-ascii) show_ascii=1;; --open) @@ -215,6 +220,17 @@ cmd_run() { esac done + if [ "$linux_preview" -eq 1 ]; then + if [ -n "$project_root" ] || [ "$explicit_binary" -eq 1 ] || [ "$show_ascii" -eq 1 ] || [ "$open_output" -eq 1 ]; then + printf 'zagkit run: --linux-preview cannot be combined with project, binary, ascii, or opener flags\n' >&2 + exit 2 + fi + if [ "$explicit_output" -eq 1 ]; then + exec "$ROOT_DIR/tools/run-linux-preview.sh" --capture-once "$output" + fi + exec "$ROOT_DIR/tools/run-linux-preview.sh" + fi + if [ "$headless_only" -eq 1 ]; then if [ -n "$project_root" ]; then source_file="$project_root/src/main.zag" @@ -278,7 +294,7 @@ PY exit 0 fi - printf 'zagkit: only --headless-only is currently implemented\n' >&2 + printf 'zagkit: select --headless-only or --linux-preview\n' >&2 exit 2 } From fe5b9cd4866d267012e6f4a52919bea3eb4654d7 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 01:28:02 -0700 Subject: [PATCH 073/103] Fuzz bounded OpenType ingestion --- tests/opentype_fuzz.zag | 45 +++++++++++++++++++++++++++++++++++++++++ tools/test-headless.sh | 2 ++ 2 files changed, 47 insertions(+) create mode 100644 tests/opentype_fuzz.zag diff --git a/tests/opentype_fuzz.zag b/tests/opentype_fuzz.zag new file mode 100644 index 0000000..181e122 --- /dev/null +++ b/tests/opentype_fuzz.zag @@ -0,0 +1,45 @@ +@import("../src/text/opentype.zag") + +fn fuzz_next(state: *i64) i64 { + state.* = (state.* * 48271) % 2147483647; + return state.*; +} + +fn main() i32 { + let seed: i64 = 918273; + let iteration: i32 = 0; + while (iteration < 20000) { + let length: i32 = (fuzz_next(&seed) % 513) as i32; + let bytes: ArrayList[u8] = make[u8](length + 1); + let index: i32 = 0; + while (index < length) { + push[u8](&bytes, (fuzz_next(&seed) & 255) as u8); + index = index + 1; + } + let face: OpenTypeFace = opentype_parse(bytes.data[0..bytes.len]); + if (face.error == OpenTypeError.none) { + if (face.units_per_em < 16 || face.units_per_em > 16384 || + face.glyph_count <= 0 || + (face.cmap_format != 4 && face.cmap_format != 12) || + face.cmap_offset < 0 || face.cmap_length <= 0 || + face.cmap_offset + face.cmap_length > face.bytes.len) { + opentype_face_free(&face); free[u8](&bytes); + _zag_eprintln("OpenType fuzz: accepted face violated bounded invariants"); + return 1; + } + let codepoint: i64 = fuzz_next(&seed) % 1114112; + let glyph: GlyphLookup = opentype_glyph_for_scalar(face, codepoint); + if (glyph.error == OpenTypeError.none && + (glyph.glyph_id < 0 || glyph.glyph_id >= face.glyph_count)) { + opentype_face_free(&face); free[u8](&bytes); + _zag_eprintln("OpenType fuzz: glyph lookup escaped declared glyph count"); + return 2; + } + } + opentype_face_free(&face); + free[u8](&bytes); + iteration = iteration + 1; + } + _zag_println("OpenType fuzz: pass=20000 fail=0"); + return 0; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index dcfdfc6..6b53584 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -20,6 +20,8 @@ cd "$root" "$tmp/unicode-contract" "$znc" tests/opentype_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/opentype-contract" "$tmp/opentype-contract" +"$znc" tests/opentype_fuzz.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/opentype-fuzz" +"$tmp/opentype-fuzz" "$znc" tests/flex_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/flex-contract" "$tmp/flex-contract" "$znc" tests/flex_adaptive_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/flex-adaptive-contract" From f95e6cbee4c74ca47f65115fe39053fb44e3437b Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 01:31:09 -0700 Subject: [PATCH 074/103] Parse OpenType horizontal metrics --- docs/architecture/text.md | 6 ++-- src/text/opentype.zag | 72 ++++++++++++++++++++++++++++++++++++- tests/opentype_contract.zag | 61 ++++++++++++++++++------------- 3 files changed, 111 insertions(+), 28 deletions(-) diff --git a/docs/architecture/text.md b/docs/architecture/text.md index 72a6a7b..9eeacb6 100644 --- a/docs/architecture/text.md +++ b/docs/architecture/text.md @@ -14,8 +14,8 @@ native widget text engines are not runtime dependencies. - Malformed leads, continuations, truncation, overlong sequences, surrogates, and values above `U+10FFFF` fail at an explicit byte offset. - `src/text/opentype.zag` copies and owns bounded SFNT/OpenType bytes, validates - the table directory and required `cmap`, `head`, and `maxp` tables, and - exposes `unitsPerEm` and glyph-count truth. + the table directory and required `cmap`, `head`, `maxp`, `hhea`, and `hmtx` + tables, and exposes `unitsPerEm`, glyph-count, and horizontal-advance truth. - Unicode-to-glyph lookup supports bounded `cmap` format 4 and format 12 subtables. A missing mapping returns glyph zero; it does not invent fallback. - The parser rejects table escapes, duplicate required tables, malformed @@ -24,7 +24,7 @@ native widget text engines are not runtime dependencies. ## Required before text is visible -1. Parse horizontal and vertical metrics, `loca`/`glyf` outlines, CFF/CFF2, +1. Parse vertical metrics, `loca`/`glyf` outlines, CFF/CFF2, variation axes, color-glyph tables, and font metadata. 2. Implement normalization, grapheme/word/sentence segmentation, script runs, bidi resolution, line breaking, fallback, and locale-aware shaping. diff --git a/src/text/opentype.zag b/src/text/opentype.zag index b4363a1..07c163b 100644 --- a/src/text/opentype.zag +++ b/src/text/opentype.zag @@ -16,6 +16,8 @@ enum OpenTypeError { missing_cmap, missing_head, missing_maxp, + missing_hhea, + missing_hmtx, invalid_metrics, unsupported_cmap, invalid_cmap, @@ -30,6 +32,9 @@ struct OpenTypeFace { cmap_offset: i32, cmap_length: i32, cmap_format: i32, + hmtx_offset: i32, + hmtx_length: i32, + horizontal_metric_count: i32, error: OpenTypeError, error_offset: i64, } @@ -39,6 +44,11 @@ struct GlyphLookup { error: OpenTypeError, } +struct GlyphHorizontalMetric { + advance_width: i32, + error: OpenTypeError, +} + fn opentype_u16(bytes: []u8, offset: i32) i32 { return ((bytes[offset] as i32) << 8) | (bytes[offset + 1] as i32); } @@ -58,6 +68,9 @@ fn opentype_face_empty() OpenTypeFace { .cmap_offset = 0, .cmap_length = 0, .cmap_format = 0, + .hmtx_offset = 0, + .hmtx_length = 0, + .horizontal_metric_count = 0, .error = OpenTypeError.none, .error_offset = 0, }; @@ -117,6 +130,10 @@ fn opentype_parse(data: []u8) OpenTypeFace { let head_length: i32 = 0; let maxp_offset: i32 = 0; let maxp_length: i32 = 0; + let hhea_offset: i32 = 0; + let hhea_length: i32 = 0; + let hmtx_offset: i32 = 0; + let hmtx_length: i32 = 0; let table_index: i32 = 0; while (table_index < table_count) { let record: i32 = 12 + table_index * 16; @@ -144,13 +161,27 @@ fn opentype_parse(data: []u8) OpenTypeFace { return face; } maxp_offset = offset64 as i32; maxp_length = length64 as i32; + } else if (opentype_tag(bytes, record, 104, 104, 101, 97) != 0) { + if (hhea_length != 0) { + opentype_fail(&face, OpenTypeError.duplicate_required_table, record); + return face; + } + hhea_offset = offset64 as i32; hhea_length = length64 as i32; + } else if (opentype_tag(bytes, record, 104, 109, 116, 120) != 0) { + if (hmtx_length != 0) { + opentype_fail(&face, OpenTypeError.duplicate_required_table, record); + return face; + } + hmtx_offset = offset64 as i32; hmtx_length = length64 as i32; } table_index = table_index + 1; } if (cmap_table_length == 0) { opentype_fail(&face, OpenTypeError.missing_cmap, 12); return face; } if (head_length == 0) { opentype_fail(&face, OpenTypeError.missing_head, 12); return face; } if (maxp_length == 0) { opentype_fail(&face, OpenTypeError.missing_maxp, 12); return face; } - if (head_length < 20 || maxp_length < 6 || cmap_table_length < 4) { + if (hhea_length == 0) { opentype_fail(&face, OpenTypeError.missing_hhea, 12); return face; } + if (hmtx_length == 0) { opentype_fail(&face, OpenTypeError.missing_hmtx, 12); return face; } + if (head_length < 20 || maxp_length < 6 || cmap_table_length < 4 || hhea_length < 36) { opentype_fail(&face, OpenTypeError.invalid_metrics, 12); return face; } face.units_per_em = opentype_u16(bytes, head_offset + 18); @@ -159,6 +190,20 @@ fn opentype_parse(data: []u8) OpenTypeFace { opentype_fail(&face, OpenTypeError.invalid_metrics, head_offset + 18); return face; } + face.horizontal_metric_count = opentype_u16(bytes, hhea_offset + 34); + if (face.horizontal_metric_count <= 0 || + face.horizontal_metric_count > face.glyph_count) { + opentype_fail(&face, OpenTypeError.invalid_metrics, hhea_offset + 34); + return face; + } + let required_hmtx: i64 = (face.horizontal_metric_count as i64) * 4 + + ((face.glyph_count - face.horizontal_metric_count) as i64) * 2; + if (required_hmtx > hmtx_length as i64) { + opentype_fail(&face, OpenTypeError.invalid_metrics, hmtx_offset); + return face; + } + face.hmtx_offset = hmtx_offset; + face.hmtx_length = hmtx_length; let cmap_count: i32 = opentype_u16(bytes, cmap_table_offset + 2); if (cmap_count <= 0 || cmap_count > 4096 || 4 + (cmap_count as i64) * 8 > cmap_table_length as i64) { @@ -304,6 +349,31 @@ fn opentype_glyph_for_scalar(face: OpenTypeFace, codepoint: i64) GlyphLookup { return opentype_lookup_fail(OpenTypeError.unsupported_cmap); } +fn opentype_horizontal_metric(face: OpenTypeFace, + glyph_id: i32) GlyphHorizontalMetric { + if (face.error != OpenTypeError.none) { + return GlyphHorizontalMetric{ .advance_width = 0, .error = face.error }; + } + if (glyph_id < 0 || glyph_id >= face.glyph_count || + face.horizontal_metric_count <= 0) { + return GlyphHorizontalMetric{ + .advance_width = 0, .error = OpenTypeError.invalid_glyph }; + } + let metric_index: i32 = glyph_id; + if (metric_index >= face.horizontal_metric_count) { + metric_index = face.horizontal_metric_count - 1; + } + let offset: i32 = face.hmtx_offset + metric_index * 4; + if (offset < face.hmtx_offset || offset + 2 > face.hmtx_offset + face.hmtx_length) { + return GlyphHorizontalMetric{ + .advance_width = 0, .error = OpenTypeError.invalid_metrics }; + } + return GlyphHorizontalMetric{ + .advance_width = opentype_u16(face.bytes.data[0..face.bytes.len], offset), + .error = OpenTypeError.none, + }; +} + fn opentype_face_free(face: *OpenTypeFace) void { free[u8](&face.*.bytes); } diff --git a/tests/opentype_contract.zag b/tests/opentype_contract.zag index 5c08682..80691ce 100644 --- a/tests/opentype_contract.zag +++ b/tests/opentype_contract.zag @@ -30,35 +30,44 @@ fn fixture_tag(bytes: *ArrayList[u8], offset: i32, tag: []u8) void { } fn fixture_font() ArrayList[u8] { - let bytes: ArrayList[u8] = make[u8](136); + let bytes: ArrayList[u8] = make[u8](248); let i: i32 = 0; - while (i < 136) { push[u8](&bytes, 0); i = i + 1; } - fixture_u32(&bytes, 0, 65536); fixture_u16(&bytes, 4, 3); - fixture_tag(&bytes, 12, "cmap"); fixture_u32(&bytes, 20, 60); fixture_u32(&bytes, 24, 44); - fixture_tag(&bytes, 28, "head"); fixture_u32(&bytes, 36, 104); fixture_u32(&bytes, 40, 20); - fixture_tag(&bytes, 44, "maxp"); fixture_u32(&bytes, 52, 124); fixture_u32(&bytes, 56, 6); - fixture_u16(&bytes, 60, 0); fixture_u16(&bytes, 62, 1); - fixture_u16(&bytes, 64, 3); fixture_u16(&bytes, 66, 1); fixture_u32(&bytes, 68, 12); - fixture_u16(&bytes, 72, 4); fixture_u16(&bytes, 74, 32); - fixture_u16(&bytes, 76, 0); fixture_u16(&bytes, 78, 4); - fixture_u16(&bytes, 80, 4); fixture_u16(&bytes, 82, 1); fixture_u16(&bytes, 84, 0); - fixture_u16(&bytes, 86, 65); fixture_u16(&bytes, 88, 65535); fixture_u16(&bytes, 90, 0); - fixture_u16(&bytes, 92, 65); fixture_u16(&bytes, 94, 65535); - fixture_u16(&bytes, 96, 65474); fixture_u16(&bytes, 98, 1); - fixture_u16(&bytes, 100, 0); fixture_u16(&bytes, 102, 0); - fixture_u16(&bytes, 122, 1000); - fixture_u32(&bytes, 124, 65536); fixture_u16(&bytes, 128, 10); + while (i < 248) { push[u8](&bytes, 0); i = i + 1; } + fixture_u32(&bytes, 0, 65536); fixture_u16(&bytes, 4, 5); + fixture_tag(&bytes, 12, "cmap"); fixture_u32(&bytes, 20, 96); fixture_u32(&bytes, 24, 44); + fixture_tag(&bytes, 28, "head"); fixture_u32(&bytes, 36, 144); fixture_u32(&bytes, 40, 20); + fixture_tag(&bytes, 44, "maxp"); fixture_u32(&bytes, 52, 164); fixture_u32(&bytes, 56, 6); + fixture_tag(&bytes, 60, "hhea"); fixture_u32(&bytes, 68, 172); fixture_u32(&bytes, 72, 36); + fixture_tag(&bytes, 76, "hmtx"); fixture_u32(&bytes, 84, 208); fixture_u32(&bytes, 88, 40); + fixture_u16(&bytes, 96, 0); fixture_u16(&bytes, 98, 1); + fixture_u16(&bytes, 100, 3); fixture_u16(&bytes, 102, 1); fixture_u32(&bytes, 104, 12); + fixture_u16(&bytes, 108, 4); fixture_u16(&bytes, 110, 32); + fixture_u16(&bytes, 112, 0); fixture_u16(&bytes, 114, 4); + fixture_u16(&bytes, 116, 4); fixture_u16(&bytes, 118, 1); fixture_u16(&bytes, 120, 0); + fixture_u16(&bytes, 122, 65); fixture_u16(&bytes, 124, 65535); fixture_u16(&bytes, 126, 0); + fixture_u16(&bytes, 128, 65); fixture_u16(&bytes, 130, 65535); + fixture_u16(&bytes, 132, 65474); fixture_u16(&bytes, 134, 1); + fixture_u16(&bytes, 136, 0); fixture_u16(&bytes, 138, 0); + fixture_u16(&bytes, 162, 1000); + fixture_u32(&bytes, 164, 65536); fixture_u16(&bytes, 168, 10); + fixture_u16(&bytes, 206, 10); + let metric: i32 = 0; + while (metric < 10) { + fixture_u16(&bytes, 208 + metric * 4, 500 + metric * 10); + fixture_u16(&bytes, 210 + metric * 4, 0); + metric = metric + 1; + } return bytes; } fn fixture_font_format12() ArrayList[u8] { let bytes: ArrayList[u8] = fixture_font(); - fixture_u16(&bytes, 66, 10); - fixture_u16(&bytes, 72, 12); fixture_u16(&bytes, 74, 0); - fixture_u32(&bytes, 76, 28); fixture_u32(&bytes, 80, 0); - fixture_u32(&bytes, 84, 1); - fixture_u32(&bytes, 88, 128512); fixture_u32(&bytes, 92, 128512); - fixture_u32(&bytes, 96, 4); + fixture_u16(&bytes, 102, 10); + fixture_u16(&bytes, 108, 12); fixture_u16(&bytes, 110, 0); + fixture_u32(&bytes, 112, 28); fixture_u32(&bytes, 116, 0); + fixture_u32(&bytes, 120, 1); + fixture_u32(&bytes, 124, 128512); fixture_u32(&bytes, 128, 128512); + fixture_u32(&bytes, 132, 4); return bytes; } @@ -68,7 +77,7 @@ fn parse_contract(state: *TestState) void { expect(state, face.error == OpenTypeError.none, "bounded SFNT directory and required tables parse"); expect(state, face.units_per_em == 1000 && face.glyph_count == 10 && - face.cmap_format == 4, + face.cmap_format == 4 && face.horizontal_metric_count == 10, "font metrics and selected cmap format remain explicit"); let mapped: GlyphLookup = opentype_glyph_for_scalar(face, 65); let missing: GlyphLookup = opentype_glyph_for_scalar(face, 66); @@ -79,6 +88,10 @@ fn parse_contract(state: *TestState) void { let invalid: GlyphLookup = opentype_glyph_for_scalar(face, 55296); expect(state, invalid.error == OpenTypeError.invalid_codepoint, "surrogate lookup fails before cmap access"); + let horizontal: GlyphHorizontalMetric = opentype_horizontal_metric(face, 3); + expect(state, horizontal.error == OpenTypeError.none && + horizontal.advance_width == 530, + "glyph advance widths come from the owned hmtx table"); opentype_face_free(&face); free[u8](&bytes); let format12_bytes: ArrayList[u8] = fixture_font_format12(); From 4a288f060d9f86989ba9168523522d2cc741cbb5 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 01:40:46 -0700 Subject: [PATCH 075/103] Decode bounded TrueType glyph outlines --- docs/architecture/text.md | 9 +- src/text/opentype.zag | 234 ++++++++++++++++++++++++++++++++++++ tests/opentype_contract.zag | 95 ++++++++++----- tools/test-system-font.zag | 14 +++ 4 files changed, 322 insertions(+), 30 deletions(-) diff --git a/docs/architecture/text.md b/docs/architecture/text.md index 9eeacb6..9625221 100644 --- a/docs/architecture/text.md +++ b/docs/architecture/text.md @@ -16,6 +16,11 @@ native widget text engines are not runtime dependencies. - `src/text/opentype.zag` copies and owns bounded SFNT/OpenType bytes, validates the table directory and required `cmap`, `head`, `maxp`, `hhea`, and `hmtx` tables, and exposes `unitsPerEm`, glyph-count, and horizontal-advance truth. +- TrueType `loca` and simple `glyf` contours decode into bounded, exact + font-unit points with explicit on-curve flags and contour ends. Empty glyphs + are valid; malformed locations, flags, coordinates, and bounds fail closed. +- Composite TrueType glyphs and CFF/CFF2 outlines remain explicitly + unsupported rather than being flattened incorrectly. - Unicode-to-glyph lookup supports bounded `cmap` format 4 and format 12 subtables. A missing mapping returns glyph zero; it does not invent fallback. - The parser rejects table escapes, duplicate required tables, malformed @@ -24,8 +29,8 @@ native widget text engines are not runtime dependencies. ## Required before text is visible -1. Parse vertical metrics, `loca`/`glyf` outlines, CFF/CFF2, - variation axes, color-glyph tables, and font metadata. +1. Parse vertical metrics, composite TrueType outlines, CFF/CFF2, variation + axes, color-glyph tables, and font metadata. 2. Implement normalization, grapheme/word/sentence segmentation, script runs, bidi resolution, line breaking, fallback, and locale-aware shaping. 3. Implement OpenType GSUB/GPOS and variation application with deterministic diff --git a/src/text/opentype.zag b/src/text/opentype.zag index 07c163b..bca3ba7 100644 --- a/src/text/opentype.zag +++ b/src/text/opentype.zag @@ -23,6 +23,8 @@ enum OpenTypeError { invalid_cmap, invalid_codepoint, invalid_glyph, + invalid_outline, + unsupported_outline, } struct OpenTypeFace { @@ -35,6 +37,11 @@ struct OpenTypeFace { hmtx_offset: i32, hmtx_length: i32, horizontal_metric_count: i32, + loca_offset: i32, + loca_length: i32, + glyf_offset: i32, + glyf_length: i32, + index_to_loc_format: i32, error: OpenTypeError, error_offset: i64, } @@ -49,6 +56,22 @@ struct GlyphHorizontalMetric { error: OpenTypeError, } +struct GlyphOutlinePoint { + x: i32, + y: i32, + on_curve: i32, +} + +struct GlyphOutline { + points: ArrayList[GlyphOutlinePoint], + contour_ends: ArrayList[i32], + x_min: i32, + y_min: i32, + x_max: i32, + y_max: i32, + error: OpenTypeError, +} + fn opentype_u16(bytes: []u8, offset: i32) i32 { return ((bytes[offset] as i32) << 8) | (bytes[offset + 1] as i32); } @@ -60,6 +83,12 @@ fn opentype_u32(bytes: []u8, offset: i32) i64 { (bytes[offset + 3] as i64); } +fn opentype_i16(bytes: []u8, offset: i32) i32 { + let value: i32 = opentype_u16(bytes, offset); + if (value >= 32768) { return value - 65536; } + return value; +} + fn opentype_face_empty() OpenTypeFace { return OpenTypeFace{ .bytes = make[u8](1), @@ -71,6 +100,11 @@ fn opentype_face_empty() OpenTypeFace { .hmtx_offset = 0, .hmtx_length = 0, .horizontal_metric_count = 0, + .loca_offset = 0, + .loca_length = 0, + .glyf_offset = 0, + .glyf_length = 0, + .index_to_loc_format = 0, .error = OpenTypeError.none, .error_offset = 0, }; @@ -134,6 +168,10 @@ fn opentype_parse(data: []u8) OpenTypeFace { let hhea_length: i32 = 0; let hmtx_offset: i32 = 0; let hmtx_length: i32 = 0; + let loca_offset: i32 = 0; + let loca_length: i32 = 0; + let glyf_offset: i32 = 0; + let glyf_length: i32 = 0; let table_index: i32 = 0; while (table_index < table_count) { let record: i32 = 12 + table_index * 16; @@ -173,6 +211,18 @@ fn opentype_parse(data: []u8) OpenTypeFace { return face; } hmtx_offset = offset64 as i32; hmtx_length = length64 as i32; + } else if (opentype_tag(bytes, record, 108, 111, 99, 97) != 0) { + if (loca_length != 0) { + opentype_fail(&face, OpenTypeError.duplicate_required_table, record); + return face; + } + loca_offset = offset64 as i32; loca_length = length64 as i32; + } else if (opentype_tag(bytes, record, 103, 108, 121, 102) != 0) { + if (glyf_length != 0) { + opentype_fail(&face, OpenTypeError.duplicate_required_table, record); + return face; + } + glyf_offset = offset64 as i32; glyf_length = length64 as i32; } table_index = table_index + 1; } @@ -204,6 +254,25 @@ fn opentype_parse(data: []u8) OpenTypeFace { } face.hmtx_offset = hmtx_offset; face.hmtx_length = hmtx_length; + if ((loca_length == 0) != (glyf_length == 0)) { + opentype_fail(&face, OpenTypeError.invalid_outline, 12); return face; + } + if (loca_length != 0) { + if (head_length < 54) { + opentype_fail(&face, OpenTypeError.invalid_outline, head_offset); return face; + } + face.index_to_loc_format = opentype_i16(bytes, head_offset + 50); + if (face.index_to_loc_format != 0 && face.index_to_loc_format != 1) { + opentype_fail(&face, OpenTypeError.invalid_outline, head_offset + 50); return face; + } + let required_loca: i64 = (face.glyph_count as i64 + 1) * + (2 + face.index_to_loc_format * 2) as i64; + if (required_loca > loca_length as i64) { + opentype_fail(&face, OpenTypeError.invalid_outline, loca_offset); return face; + } + face.loca_offset = loca_offset; face.loca_length = loca_length; + face.glyf_offset = glyf_offset; face.glyf_length = glyf_length; + } let cmap_count: i32 = opentype_u16(bytes, cmap_table_offset + 2); if (cmap_count <= 0 || cmap_count > 4096 || 4 + (cmap_count as i64) * 8 > cmap_table_length as i64) { @@ -374,6 +443,171 @@ fn opentype_horizontal_metric(face: OpenTypeFace, }; } +fn opentype_outline_empty() GlyphOutline { + return GlyphOutline{ + .points = make[GlyphOutlinePoint](1), + .contour_ends = make[i32](1), + .x_min = 0, .y_min = 0, .x_max = 0, .y_max = 0, + .error = OpenTypeError.none, + }; +} + +fn opentype_outline_fail(outline: *GlyphOutline, error: OpenTypeError) void { + outline.*.error = error; +} + +fn opentype_glyph_data_offset(face: OpenTypeFace, glyph_id: i32) i64 { + let bytes: []u8 = face.bytes.data[0..face.bytes.len]; + if (face.index_to_loc_format == 0) { + return (opentype_u16(bytes, face.loca_offset + glyph_id * 2) as i64) * 2; + } + return opentype_u32(bytes, face.loca_offset + glyph_id * 4); +} + +// Decode one TrueType simple glyph into exact font-unit contour points. Curves +// remain quadratic at this layer: consecutive off-curve points imply the +// standard midpoint when the renderer converts the contour into a Path. +fn opentype_glyph_outline(face: OpenTypeFace, glyph_id: i32) GlyphOutline { + let outline: GlyphOutline = opentype_outline_empty(); + if (face.error != OpenTypeError.none) { + opentype_outline_fail(&outline, face.error); return outline; + } + if (glyph_id < 0 || glyph_id >= face.glyph_count) { + opentype_outline_fail(&outline, OpenTypeError.invalid_glyph); return outline; + } + if (face.loca_length == 0 || face.glyf_length == 0) { + opentype_outline_fail(&outline, OpenTypeError.unsupported_outline); return outline; + } + let start64: i64 = opentype_glyph_data_offset(face, glyph_id); + let end64: i64 = opentype_glyph_data_offset(face, glyph_id + 1); + if (start64 < 0 || end64 < start64 || end64 > face.glyf_length as i64) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + if (start64 == end64) { return outline; } + if (end64 - start64 < 10) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + let bytes: []u8 = face.bytes.data[0..face.bytes.len]; + let glyph_start: i32 = face.glyf_offset + start64 as i32; + let glyph_end: i32 = face.glyf_offset + end64 as i32; + let contour_count: i32 = opentype_i16(bytes, glyph_start); + if (contour_count < 0) { + opentype_outline_fail(&outline, OpenTypeError.unsupported_outline); return outline; + } + if (contour_count > 4096 || glyph_start + 10 + contour_count * 2 + 2 > glyph_end) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + outline.x_min = opentype_i16(bytes, glyph_start + 2); + outline.y_min = opentype_i16(bytes, glyph_start + 4); + outline.x_max = opentype_i16(bytes, glyph_start + 6); + outline.y_max = opentype_i16(bytes, glyph_start + 8); + if (outline.x_min > outline.x_max || outline.y_min > outline.y_max) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + let cursor: i32 = glyph_start + 10; + let contour_index: i32 = 0; + let previous_end: i32 = 0 - 1; + while (contour_index < contour_count) { + let contour_end: i32 = opentype_u16(bytes, cursor); + if (contour_end <= previous_end) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + push[i32](&outline.contour_ends, contour_end); + previous_end = contour_end; + cursor = cursor + 2; contour_index = contour_index + 1; + } + let point_count: i32 = previous_end + 1; + if (point_count < 0 || point_count > 65535 || cursor + 2 > glyph_end) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + let instruction_length: i32 = opentype_u16(bytes, cursor); + cursor = cursor + 2; + if (cursor + instruction_length > glyph_end) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + cursor = cursor + instruction_length; + let flags: ArrayList[u8] = make[u8](point_count + 1); + while (flags.len < point_count) { + if (cursor >= glyph_end) { + free[u8](&flags); opentype_outline_fail(&outline, OpenTypeError.invalid_outline); + return outline; + } + let flag: u8 = bytes[cursor]; cursor = cursor + 1; + let repeat: i32 = 1; + if (((flag as i32) & 8) != 0) { + if (cursor >= glyph_end) { + free[u8](&flags); opentype_outline_fail(&outline, OpenTypeError.invalid_outline); + return outline; + } + repeat = (bytes[cursor] as i32) + 1; cursor = cursor + 1; + } + if (flags.len + repeat > point_count) { + free[u8](&flags); opentype_outline_fail(&outline, OpenTypeError.invalid_outline); + return outline; + } + let repeat_index: i32 = 0; + while (repeat_index < repeat) { + push[u8](&flags, flag); repeat_index = repeat_index + 1; + } + } + let x: i32 = 0; + let point_index: i32 = 0; + while (point_index < point_count) { + let flag: i32 = flags.data[point_index] as i32; + if ((flag & 2) != 0) { + if (cursor >= glyph_end) { + free[u8](&flags); opentype_outline_fail(&outline, OpenTypeError.invalid_outline); + return outline; + } + let delta: i32 = bytes[cursor] as i32; cursor = cursor + 1; + if ((flag & 16) != 0) { x = x + delta; } else { x = x - delta; } + } else if ((flag & 16) == 0) { + if (cursor + 2 > glyph_end) { + free[u8](&flags); opentype_outline_fail(&outline, OpenTypeError.invalid_outline); + return outline; + } + x = x + opentype_i16(bytes, cursor); cursor = cursor + 2; + } + push[GlyphOutlinePoint](&outline.points, GlyphOutlinePoint{ + .x = x, .y = 0, .on_curve = (flag & 1) as i32 }); + point_index = point_index + 1; + } + let y: i32 = 0; + point_index = 0; + while (point_index < point_count) { + let flag: i32 = flags.data[point_index] as i32; + if ((flag & 4) != 0) { + if (cursor >= glyph_end) { + free[u8](&flags); opentype_outline_fail(&outline, OpenTypeError.invalid_outline); + return outline; + } + let delta: i32 = bytes[cursor] as i32; cursor = cursor + 1; + if ((flag & 32) != 0) { y = y + delta; } else { y = y - delta; } + } else if ((flag & 32) == 0) { + if (cursor + 2 > glyph_end) { + free[u8](&flags); opentype_outline_fail(&outline, OpenTypeError.invalid_outline); + return outline; + } + y = y + opentype_i16(bytes, cursor); cursor = cursor + 2; + } + outline.points.data[point_index].y = y; + if (outline.points.data[point_index].x < outline.x_min || + outline.points.data[point_index].x > outline.x_max || + y < outline.y_min || y > outline.y_max) { + free[u8](&flags); opentype_outline_fail(&outline, OpenTypeError.invalid_outline); + return outline; + } + point_index = point_index + 1; + } + free[u8](&flags); + return outline; +} + +fn opentype_glyph_outline_free(outline: *GlyphOutline) void { + free[GlyphOutlinePoint](&outline.*.points); + free[i32](&outline.*.contour_ends); +} + fn opentype_face_free(face: *OpenTypeFace) void { free[u8](&face.*.bytes); } diff --git a/tests/opentype_contract.zag b/tests/opentype_contract.zag index 80691ce..46c09c3 100644 --- a/tests/opentype_contract.zag +++ b/tests/opentype_contract.zag @@ -30,44 +30,55 @@ fn fixture_tag(bytes: *ArrayList[u8], offset: i32, tag: []u8) void { } fn fixture_font() ArrayList[u8] { - let bytes: ArrayList[u8] = make[u8](248); + let bytes: ArrayList[u8] = make[u8](360); let i: i32 = 0; - while (i < 248) { push[u8](&bytes, 0); i = i + 1; } - fixture_u32(&bytes, 0, 65536); fixture_u16(&bytes, 4, 5); - fixture_tag(&bytes, 12, "cmap"); fixture_u32(&bytes, 20, 96); fixture_u32(&bytes, 24, 44); - fixture_tag(&bytes, 28, "head"); fixture_u32(&bytes, 36, 144); fixture_u32(&bytes, 40, 20); - fixture_tag(&bytes, 44, "maxp"); fixture_u32(&bytes, 52, 164); fixture_u32(&bytes, 56, 6); - fixture_tag(&bytes, 60, "hhea"); fixture_u32(&bytes, 68, 172); fixture_u32(&bytes, 72, 36); - fixture_tag(&bytes, 76, "hmtx"); fixture_u32(&bytes, 84, 208); fixture_u32(&bytes, 88, 40); - fixture_u16(&bytes, 96, 0); fixture_u16(&bytes, 98, 1); - fixture_u16(&bytes, 100, 3); fixture_u16(&bytes, 102, 1); fixture_u32(&bytes, 104, 12); - fixture_u16(&bytes, 108, 4); fixture_u16(&bytes, 110, 32); - fixture_u16(&bytes, 112, 0); fixture_u16(&bytes, 114, 4); - fixture_u16(&bytes, 116, 4); fixture_u16(&bytes, 118, 1); fixture_u16(&bytes, 120, 0); - fixture_u16(&bytes, 122, 65); fixture_u16(&bytes, 124, 65535); fixture_u16(&bytes, 126, 0); - fixture_u16(&bytes, 128, 65); fixture_u16(&bytes, 130, 65535); - fixture_u16(&bytes, 132, 65474); fixture_u16(&bytes, 134, 1); - fixture_u16(&bytes, 136, 0); fixture_u16(&bytes, 138, 0); - fixture_u16(&bytes, 162, 1000); - fixture_u32(&bytes, 164, 65536); fixture_u16(&bytes, 168, 10); - fixture_u16(&bytes, 206, 10); + while (i < 360) { push[u8](&bytes, 0); i = i + 1; } + fixture_u32(&bytes, 0, 65536); fixture_u16(&bytes, 4, 7); + fixture_tag(&bytes, 12, "cmap"); fixture_u32(&bytes, 20, 128); fixture_u32(&bytes, 24, 44); + fixture_tag(&bytes, 28, "head"); fixture_u32(&bytes, 36, 176); fixture_u32(&bytes, 40, 54); + fixture_tag(&bytes, 44, "maxp"); fixture_u32(&bytes, 52, 232); fixture_u32(&bytes, 56, 6); + fixture_tag(&bytes, 60, "hhea"); fixture_u32(&bytes, 68, 240); fixture_u32(&bytes, 72, 36); + fixture_tag(&bytes, 76, "hmtx"); fixture_u32(&bytes, 84, 276); fixture_u32(&bytes, 88, 40); + fixture_tag(&bytes, 92, "loca"); fixture_u32(&bytes, 100, 316); fixture_u32(&bytes, 104, 22); + fixture_tag(&bytes, 108, "glyf"); fixture_u32(&bytes, 116, 340); fixture_u32(&bytes, 120, 20); + fixture_u16(&bytes, 128, 0); fixture_u16(&bytes, 130, 1); + fixture_u16(&bytes, 132, 3); fixture_u16(&bytes, 134, 1); fixture_u32(&bytes, 136, 12); + fixture_u16(&bytes, 140, 4); fixture_u16(&bytes, 142, 32); + fixture_u16(&bytes, 144, 0); fixture_u16(&bytes, 146, 4); + fixture_u16(&bytes, 148, 4); fixture_u16(&bytes, 150, 1); fixture_u16(&bytes, 152, 0); + fixture_u16(&bytes, 154, 65); fixture_u16(&bytes, 156, 65535); fixture_u16(&bytes, 158, 0); + fixture_u16(&bytes, 160, 65); fixture_u16(&bytes, 162, 65535); + fixture_u16(&bytes, 164, 65474); fixture_u16(&bytes, 166, 1); + fixture_u16(&bytes, 168, 0); fixture_u16(&bytes, 170, 0); + fixture_u16(&bytes, 194, 1000); + fixture_u16(&bytes, 226, 0); + fixture_u32(&bytes, 232, 65536); fixture_u16(&bytes, 236, 10); + fixture_u16(&bytes, 274, 10); let metric: i32 = 0; while (metric < 10) { - fixture_u16(&bytes, 208 + metric * 4, 500 + metric * 10); - fixture_u16(&bytes, 210 + metric * 4, 0); + fixture_u16(&bytes, 276 + metric * 4, 500 + metric * 10); + fixture_u16(&bytes, 278 + metric * 4, 0); metric = metric + 1; } + let location: i32 = 4; + while (location <= 10) { fixture_u16(&bytes, 316 + location * 2, 10); location = location + 1; } + fixture_u16(&bytes, 340, 1); + fixture_u16(&bytes, 342, 0); fixture_u16(&bytes, 344, 0); + fixture_u16(&bytes, 346, 100); fixture_u16(&bytes, 348, 100); + fixture_u16(&bytes, 350, 2); fixture_u16(&bytes, 352, 0); + bytes.data[354] = 49; bytes.data[355] = 51; bytes.data[356] = 39; + bytes.data[357] = 100; bytes.data[358] = 100; bytes.data[359] = 100; return bytes; } fn fixture_font_format12() ArrayList[u8] { let bytes: ArrayList[u8] = fixture_font(); - fixture_u16(&bytes, 102, 10); - fixture_u16(&bytes, 108, 12); fixture_u16(&bytes, 110, 0); - fixture_u32(&bytes, 112, 28); fixture_u32(&bytes, 116, 0); - fixture_u32(&bytes, 120, 1); - fixture_u32(&bytes, 124, 128512); fixture_u32(&bytes, 128, 128512); - fixture_u32(&bytes, 132, 4); + fixture_u16(&bytes, 134, 10); + fixture_u16(&bytes, 140, 12); fixture_u16(&bytes, 142, 0); + fixture_u32(&bytes, 144, 28); fixture_u32(&bytes, 148, 0); + fixture_u32(&bytes, 152, 1); + fixture_u32(&bytes, 156, 128512); fixture_u32(&bytes, 160, 128512); + fixture_u32(&bytes, 164, 4); return bytes; } @@ -92,6 +103,14 @@ fn parse_contract(state: *TestState) void { expect(state, horizontal.error == OpenTypeError.none && horizontal.advance_width == 530, "glyph advance widths come from the owned hmtx table"); + let outline: GlyphOutline = opentype_glyph_outline(face, 3); + expect(state, outline.error == OpenTypeError.none && outline.points.len == 3 && + outline.contour_ends.len == 1 && outline.contour_ends.data[0] == 2 && + outline.points.data[0].x == 0 && outline.points.data[0].y == 0 && + outline.points.data[1].x == 100 && outline.points.data[1].y == 0 && + outline.points.data[2].x == 0 && outline.points.data[2].y == 100, + "simple glyf contours decode exact on-curve font-unit points"); + opentype_glyph_outline_free(&outline); opentype_face_free(&face); free[u8](&bytes); let format12_bytes: ArrayList[u8] = fixture_font_format12(); @@ -135,6 +154,26 @@ fn malformed_contract(state: *TestState) void { expect(state, duplicate.error == OpenTypeError.duplicate_required_table, "duplicate required tables fail before ambiguous selection"); opentype_face_free(&duplicate); free[u8](&duplicate_bytes); + + let escaped_loca_bytes: ArrayList[u8] = fixture_font(); + fixture_u16(&escaped_loca_bytes, 324, 100); + let escaped_loca_face: OpenTypeFace = opentype_parse( + escaped_loca_bytes.data[0..escaped_loca_bytes.len]); + let escaped_outline: GlyphOutline = opentype_glyph_outline(escaped_loca_face, 3); + expect(state, escaped_outline.error == OpenTypeError.invalid_outline, + "loca entries cannot escape the owned glyf table"); + opentype_glyph_outline_free(&escaped_outline); + opentype_face_free(&escaped_loca_face); free[u8](&escaped_loca_bytes); + + let composite_bytes: ArrayList[u8] = fixture_font(); + fixture_u16(&composite_bytes, 340, 65535); + let composite_face: OpenTypeFace = opentype_parse( + composite_bytes.data[0..composite_bytes.len]); + let composite_outline: GlyphOutline = opentype_glyph_outline(composite_face, 3); + expect(state, composite_outline.error == OpenTypeError.unsupported_outline, + "composite glyphs remain explicitly unsupported until owned decoding exists"); + opentype_glyph_outline_free(&composite_outline); + opentype_face_free(&composite_face); free[u8](&composite_bytes); } fn main() i32 { diff --git a/tools/test-system-font.zag b/tools/test-system-font.zag index 26eeaa1..665b272 100644 --- a/tools/test-system-font.zag +++ b/tools/test-system-font.zag @@ -25,6 +25,7 @@ fn main() i32 { return 5; } let index: i32 = 0; + let outline_points: i32 = 0; while (index < text.scalars.len) { let glyph: GlyphLookup = opentype_glyph_for_scalar( face, text.scalars.data[index].value); @@ -33,6 +34,17 @@ fn main() i32 { _zag_eprintln("system font smoke: required Latin glyph is unavailable"); return 6; } + if (index == 0) { + let outline: GlyphOutline = opentype_glyph_outline(face, glyph.glyph_id); + if (outline.error != OpenTypeError.none || outline.points.len == 0) { + opentype_glyph_outline_free(&outline); + utf8_decode_free(&text); opentype_face_free(&face); + _zag_eprintln("system font smoke: first Latin outline is unavailable"); + return 7; + } + outline_points = outline.points.len; + opentype_glyph_outline_free(&outline); + } index = index + 1; } _zag_print("system font smoke: unitsPerEm="); @@ -41,6 +53,8 @@ fn main() i32 { _zag_print_i64(face.glyph_count as i64); _zag_print(" mapped_scalars="); _zag_print_i64(text.scalars.len as i64); + _zag_print(" first_outline_points="); + _zag_print_i64(outline_points as i64); _zag_println(""); utf8_decode_free(&text); opentype_face_free(&face); return 0; From 20f203983507c645b2726be856f9ff661ca4befc Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 01:48:08 -0700 Subject: [PATCH 076/103] Convert TrueType contours into CPU render paths --- docs/architecture/text.md | 9 ++- src/text/glyph_path.zag | 122 ++++++++++++++++++++++++++++++++++ tests/glyph_path_contract.zag | 84 +++++++++++++++++++++++ tools/test-headless.sh | 2 + tools/test-system-font.zag | 17 ++++- 5 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 src/text/glyph_path.zag create mode 100644 tests/glyph_path_contract.zag diff --git a/docs/architecture/text.md b/docs/architecture/text.md index 9625221..0883fde 100644 --- a/docs/architecture/text.md +++ b/docs/architecture/text.md @@ -21,6 +21,11 @@ native widget text engines are not runtime dependencies. are valid; malformed locations, flags, coordinates, and bounds fail closed. - Composite TrueType glyphs and CFF/CFF2 outlines remain explicitly unsupported rather than being flattened incorrectly. +- `src/text/glyph_path.zag` converts simple TrueType contours into immutable + quadratic Zagkit paths at an exact fixed-point font size and baseline, + including implied midpoints between consecutive off-curve points. +- Those canonical paths already execute through the deterministic CPU oracle; + positioned multi-glyph run assembly and text antialiasing policy remain open. - Unicode-to-glyph lookup supports bounded `cmap` format 4 and format 12 subtables. A missing mapping returns glyph zero; it does not invent fallback. - The parser rejects table escapes, duplicate required tables, malformed @@ -35,8 +40,8 @@ native widget text engines are not runtime dependencies. bidi resolution, line breaking, fallback, and locale-aware shaping. 3. Implement OpenType GSUB/GPOS and variation application with deterministic glyph-run serialization. -4. Flatten/rasterize glyph outlines in the CPU oracle with subpixel-positioned - antialiasing and documented color-space behavior. +4. Assemble and rasterize positioned glyph runs in the CPU oracle with + subpixel antialiasing and documented color-space behavior. 5. Connect glyph runs to intrinsic measurement, selection, editing, semantics, IME composition, accessibility text navigation, Talkback, and replay. 6. Prove representative Latin, Arabic, Hebrew, Indic, CJK, emoji, combining, diff --git a/src/text/glyph_path.zag b/src/text/glyph_path.zag new file mode 100644 index 0000000..47e41e5 --- /dev/null +++ b/src/text/glyph_path.zag @@ -0,0 +1,122 @@ +@import("opentype.zag") +@import("../render/path.zag") + +enum GlyphPathError { + none, + invalid_metrics, + invalid_outline, + empty_glyph, + path_error, +} + +struct GlyphPathResult { + path: PathData, + error: GlyphPathError, +} + +fn glyph_path_result(error: GlyphPathError) GlyphPathResult { + return GlyphPathResult{ .path = path_make(PathFillRule.non_zero), .error = error }; +} + +fn glyph_path_point(point: GlyphOutlinePoint, units_per_em: i32, + font_size: i64, origin_x: i64, baseline_y: i64) PathPoint { + return path_point( + origin_x + ((point.x as i64) * font_size) / (units_per_em as i64), + baseline_y - ((point.y as i64) * font_size) / (units_per_em as i64) + ); +} + +fn glyph_path_midpoint(a: GlyphOutlinePoint, b: GlyphOutlinePoint) GlyphOutlinePoint { + return GlyphOutlinePoint{ + .x = (a.x + b.x) / 2, + .y = (a.y + b.y) / 2, + .on_curve = 1, + }; +} + +fn glyph_outline_to_path(outline: GlyphOutline, units_per_em: i32, + font_size: i64, origin_x: i64, baseline_y: i64) GlyphPathResult { + let result: GlyphPathResult = glyph_path_result(GlyphPathError.none); + if (outline.error != OpenTypeError.none) { + result.error = GlyphPathError.invalid_outline; return result; + } + if (units_per_em <= 0 || font_size <= 0 || + path_fixed_valid(font_size) == 0 || path_fixed_valid(origin_x) == 0 || + path_fixed_valid(baseline_y) == 0) { + result.error = GlyphPathError.invalid_metrics; return result; + } + if (outline.points.len == 0 && outline.contour_ends.len == 0) { + result.error = GlyphPathError.empty_glyph; return result; + } + if (outline.points.len <= 0 || outline.contour_ends.len <= 0 || + outline.contour_ends.data[outline.contour_ends.len - 1] != outline.points.len - 1) { + result.error = GlyphPathError.invalid_outline; return result; + } + let contour_start: i32 = 0; + let contour_index: i32 = 0; + while (contour_index < outline.contour_ends.len) { + let contour_end: i32 = outline.contour_ends.data[contour_index]; + if (contour_end < contour_start || contour_end >= outline.points.len) { + result.error = GlyphPathError.invalid_outline; return result; + } + let count: i32 = contour_end - contour_start + 1; + let first: GlyphOutlinePoint = outline.points.data[contour_start]; + let last: GlyphOutlinePoint = outline.points.data[contour_end]; + let start_point: GlyphOutlinePoint = first; + let local_index: i32 = 1; + let remaining: i32 = count - 1; + if (first.on_curve == 0) { + if (last.on_curve != 0) { + start_point = last; local_index = 0; remaining = count - 1; + } else { + start_point = glyph_path_midpoint(last, first); + local_index = 0; remaining = count; + } + } + if (path_move_to(&result.path, glyph_path_point(start_point, + units_per_em, font_size, origin_x, baseline_y)) != PathError.none) { + result.error = GlyphPathError.path_error; return result; + } + while (remaining > 0) { + let absolute: i32 = contour_start + (local_index % count); + let point: GlyphOutlinePoint = outline.points.data[absolute]; + if (point.on_curve != 0) { + if (path_line_to(&result.path, glyph_path_point(point, + units_per_em, font_size, origin_x, baseline_y)) != PathError.none) { + result.error = GlyphPathError.path_error; return result; + } + local_index = local_index + 1; remaining = remaining - 1; + } else { + let next_local: i32 = (local_index + 1) % count; + let next: GlyphOutlinePoint = outline.points.data[contour_start + next_local]; + let end_point: GlyphOutlinePoint = next; + let consume_next: i32 = 0; + if (next.on_curve == 0) { + end_point = glyph_path_midpoint(point, next); + } else if (remaining > 1) { + consume_next = 1; + } + if (path_quad_to(&result.path, + glyph_path_point(point, units_per_em, font_size, origin_x, baseline_y), + glyph_path_point(end_point, units_per_em, font_size, origin_x, baseline_y)) != + PathError.none) { + result.error = GlyphPathError.path_error; return result; + } + local_index = local_index + 1 + consume_next; + remaining = remaining - 1 - consume_next; + } + } + if (path_close(&result.path) != PathError.none) { + result.error = GlyphPathError.path_error; return result; + } + contour_start = contour_end + 1; contour_index = contour_index + 1; + } + if (path_seal(&result.path) != PathError.none) { + result.error = GlyphPathError.path_error; return result; + } + return result; +} + +fn glyph_path_result_free(result: *GlyphPathResult) void { + path_free(&result.*.path); +} diff --git a/tests/glyph_path_contract.zag b/tests/glyph_path_contract.zag new file mode 100644 index 0000000..4389ee2 --- /dev/null +++ b/tests/glyph_path_contract.zag @@ -0,0 +1,84 @@ +@import("../src/text/glyph_path.zag") +@import("../src/render/cpu_raster.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; _zag_print("FAIL "); _zag_println(name); + } +} + +fn triangle_outline() GlyphOutline { + let outline: GlyphOutline = opentype_outline_empty(); + outline.x_min = 0; outline.y_min = 0; outline.x_max = 100; outline.y_max = 100; + push[GlyphOutlinePoint](&outline.points, + GlyphOutlinePoint{ .x = 0, .y = 0, .on_curve = 1 }); + push[GlyphOutlinePoint](&outline.points, + GlyphOutlinePoint{ .x = 100, .y = 0, .on_curve = 1 }); + push[GlyphOutlinePoint](&outline.points, + GlyphOutlinePoint{ .x = 0, .y = 100, .on_curve = 1 }); + push[i32](&outline.contour_ends, 2); + return outline; +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + let outline: GlyphOutline = triangle_outline(); + let result: GlyphPathResult = glyph_outline_to_path( + outline, 1000, 20 * unit_scale(), 3 * unit_scale(), 8 * unit_scale()); + expect(&state, result.error == GlyphPathError.none && + path_verify(result.path) == PathError.none, + "a decoded glyph becomes one immutable canonical path"); + expect(&state, result.path.commands.len == 4 && + result.path.commands.data[0].point_1.x == 3 * unit_scale() && + result.path.commands.data[0].point_1.y == 8 * unit_scale() && + result.path.commands.data[1].point_1.x == 5 * unit_scale() && + result.path.commands.data[2].point_1.y == 6 * unit_scale(), + "font units scale around an exact baseline without pixel rounding"); + let payload: PathEncodeResult = path_encode(result.path); + let list: DisplayList = display_list_make(); + _ = display_list_add_resource(&list, render_resource_spec( + 41, RenderResourceKind.path, payload.bytes.data[0..payload.bytes.len], + path_resource_format_tag())); + path_encode_free(&payload); + let draw: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(41)); + draw.resource_id = 41; draw.paint = paint_rgba16(65535, 65535, 65535, 65535); + _ = display_list_push(&list, draw); _ = display_list_seal(&list); + let raster: CpuRasterResult = cpu_rasterize(list, 10, 10); + expect(&state, raster.error == CpuRasterError.none && + raster.rendered_ops == 1 && raster.pixels_touched > 0, + "glyph paths execute through the deterministic CPU oracle"); + cpu_raster_free(&raster); display_list_free(&list); + glyph_path_result_free(&result); opentype_glyph_outline_free(&outline); + + let curved: GlyphOutline = opentype_outline_empty(); + push[GlyphOutlinePoint](&curved.points, + GlyphOutlinePoint{ .x = 0, .y = 50, .on_curve = 0 }); + push[GlyphOutlinePoint](&curved.points, + GlyphOutlinePoint{ .x = 50, .y = 100, .on_curve = 0 }); + push[GlyphOutlinePoint](&curved.points, + GlyphOutlinePoint{ .x = 100, .y = 50, .on_curve = 1 }); + push[i32](&curved.contour_ends, 2); + let curve_path: GlyphPathResult = glyph_outline_to_path( + curved, 1000, 10 * unit_scale(), 0, 10 * unit_scale()); + expect(&state, curve_path.error == GlyphPathError.none && + curve_path.path.commands.data[0].verb == PathVerb.move_to && + curve_path.path.commands.data[1].verb == PathVerb.quad_to && + curve_path.path.commands.data[2].verb == PathVerb.quad_to, + "consecutive off-curve points preserve TrueType implied midpoints"); + glyph_path_result_free(&curve_path); opentype_glyph_outline_free(&curved); + + let empty: GlyphOutline = opentype_outline_empty(); + let empty_path: GlyphPathResult = glyph_outline_to_path(empty, 1000, unit_scale(), 0, 0); + expect(&state, empty_path.error == GlyphPathError.empty_glyph, + "space-like empty glyphs remain explicit without fabricated geometry"); + glyph_path_result_free(&empty_path); opentype_glyph_outline_free(&empty); + + _zag_print("Glyph path contract: pass="); _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 6b53584..5ac7ea2 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -22,6 +22,8 @@ cd "$root" "$tmp/opentype-contract" "$znc" tests/opentype_fuzz.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/opentype-fuzz" "$tmp/opentype-fuzz" +"$znc" tests/glyph_path_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/glyph-path-contract" +"$tmp/glyph-path-contract" "$znc" tests/flex_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/flex-contract" "$tmp/flex-contract" "$znc" tests/flex_adaptive_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/flex-adaptive-contract" diff --git a/tools/test-system-font.zag b/tools/test-system-font.zag index 665b272..e12b84e 100644 --- a/tools/test-system-font.zag +++ b/tools/test-system-font.zag @@ -1,5 +1,5 @@ @import("../src/text/unicode.zag") -@import("../src/text/opentype.zag") +@import("../src/text/glyph_path.zag") fn main() i32 { if (_zag_argc() != 2) { @@ -26,6 +26,7 @@ fn main() i32 { } let index: i32 = 0; let outline_points: i32 = 0; + let path_commands: i32 = 0; while (index < text.scalars.len) { let glyph: GlyphLookup = opentype_glyph_for_scalar( face, text.scalars.data[index].value); @@ -43,6 +44,18 @@ fn main() i32 { return 7; } outline_points = outline.points.len; + let glyph_path: GlyphPathResult = glyph_outline_to_path( + outline, face.units_per_em, 24 * unit_scale(), 0, 24 * unit_scale()); + if (glyph_path.error != GlyphPathError.none || + path_verify(glyph_path.path) != PathError.none) { + glyph_path_result_free(&glyph_path); + opentype_glyph_outline_free(&outline); + utf8_decode_free(&text); opentype_face_free(&face); + _zag_eprintln("system font smoke: first Latin path conversion failed"); + return 8; + } + path_commands = glyph_path.path.commands.len; + glyph_path_result_free(&glyph_path); opentype_glyph_outline_free(&outline); } index = index + 1; @@ -55,6 +68,8 @@ fn main() i32 { _zag_print_i64(text.scalars.len as i64); _zag_print(" first_outline_points="); _zag_print_i64(outline_points as i64); + _zag_print(" first_path_commands="); + _zag_print_i64(path_commands as i64); _zag_println(""); utf8_decode_free(&text); opentype_face_free(&face); return 0; From 12b6a91522f36b1758168773784048fc8d398d0c Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 01:49:07 -0700 Subject: [PATCH 077/103] Record fixed-point Linux compiler evidence --- docs/evidence/linux-x11-preview-2026-08-08.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/evidence/linux-x11-preview-2026-08-08.md b/docs/evidence/linux-x11-preview-2026-08-08.md index 25c134c..82bd9bd 100644 --- a/docs/evidence/linux-x11-preview-2026-08-08.md +++ b/docs/evidence/linux-x11-preview-2026-08-08.md @@ -28,6 +28,13 @@ argument seven and later. The fix lives in Zag's native lowering: preserve the exact expression-stack pointer, align before System V argument placement, and restore it after the call. Focused nine-argument field-assignment and deep X11 aggregate-return fixtures both exited `42` with the fixed compiler stage. +The source repair is committed directly in the canonical Zag checkout as +`590501f` (`Fix nested System V calls and memory-class aggregates`). On +2026-08-09 Zag then reached the bounded byte-identical self-hosting fixpoint, +installed `./znc` with SHA-256 +`f52b0167d7d0644531e76e5eebb6560a61b2a5a06d3b7a82211e5617d2924090`, +and that installed compiler passed all 36 dynamic ABI checks plus this native +X11 create/present/capture/cleanup gate. ## Honest limits From 67d2b7bdf10992dae53b2b440fae53a60c5ef131 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 01:56:38 -0700 Subject: [PATCH 078/103] Decode bounded composite TrueType glyphs --- docs/architecture/text.md | 14 ++- src/text/opentype.zag | 178 +++++++++++++++++++++++++++-- tests/opentype_contract.zag | 128 ++++++++------------- tests/opentype_fuzz.zag | 52 ++++++++- tests/support/opentype_fixture.zag | 98 ++++++++++++++++ tools/test-system-font.zag | 18 +++ 6 files changed, 392 insertions(+), 96 deletions(-) create mode 100644 tests/support/opentype_fixture.zag diff --git a/docs/architecture/text.md b/docs/architecture/text.md index 0883fde..d7575ec 100644 --- a/docs/architecture/text.md +++ b/docs/architecture/text.md @@ -16,11 +16,15 @@ native widget text engines are not runtime dependencies. - `src/text/opentype.zag` copies and owns bounded SFNT/OpenType bytes, validates the table directory and required `cmap`, `head`, `maxp`, `hhea`, and `hmtx` tables, and exposes `unitsPerEm`, glyph-count, and horizontal-advance truth. -- TrueType `loca` and simple `glyf` contours decode into bounded, exact +- TrueType `loca`, simple `glyf` contours, and recursively bounded composite + components decode into exact font-unit points with explicit on-curve flags and contour ends. Empty glyphs are valid; malformed locations, flags, coordinates, and bounds fail closed. -- Composite TrueType glyphs and CFF/CFF2 outlines remain explicitly - unsupported rather than being flattened incorrectly. +- Composite translation, point attachment, uniform/axis/two-by-two F2Dot14 + transforms, and optional instruction bounds are interpreted without executing + font bytecode. Cycles, excessive depth, and excessive component work fail. +- CFF/CFF2 outlines remain explicitly unsupported rather than being flattened + incorrectly. - `src/text/glyph_path.zag` converts simple TrueType contours into immutable quadratic Zagkit paths at an exact fixed-point font size and baseline, including implied midpoints between consecutive off-curve points. @@ -34,8 +38,8 @@ native widget text engines are not runtime dependencies. ## Required before text is visible -1. Parse vertical metrics, composite TrueType outlines, CFF/CFF2, variation - axes, color-glyph tables, and font metadata. +1. Parse vertical metrics, CFF/CFF2 outlines, variation axes, color-glyph + tables, and font metadata. 2. Implement normalization, grapheme/word/sentence segmentation, script runs, bidi resolution, line breaking, fallback, and locale-aware shaping. 3. Implement OpenType GSUB/GPOS and variation application with deterministic diff --git a/src/text/opentype.zag b/src/text/opentype.zag index bca3ba7..17691b1 100644 --- a/src/text/opentype.zag +++ b/src/text/opentype.zag @@ -464,10 +464,22 @@ fn opentype_glyph_data_offset(face: OpenTypeFace, glyph_id: i32) i64 { return opentype_u32(bytes, face.loca_offset + glyph_id * 4); } +fn opentype_i8(value: u8) i32 { + let result: i32 = value as i32; + if (result >= 128) { return result - 256; } + return result; +} + +fn opentype_f2dot14_round(value: i64) i32 { + if (value >= 0) { return ((value + 8192) / 16384) as i32; } + return ((value - 8192) / 16384) as i32; +} + // Decode one TrueType simple glyph into exact font-unit contour points. Curves // remain quadratic at this layer: consecutive off-curve points imply the // standard midpoint when the renderer converts the contour into a Path. -fn opentype_glyph_outline(face: OpenTypeFace, glyph_id: i32) GlyphOutline { +fn opentype_glyph_outline_depth(face: OpenTypeFace, glyph_id: i32, + depth: i32) GlyphOutline { let outline: GlyphOutline = opentype_outline_empty(); if (face.error != OpenTypeError.none) { opentype_outline_fail(&outline, face.error); return outline; @@ -475,6 +487,9 @@ fn opentype_glyph_outline(face: OpenTypeFace, glyph_id: i32) GlyphOutline { if (glyph_id < 0 || glyph_id >= face.glyph_count) { opentype_outline_fail(&outline, OpenTypeError.invalid_glyph); return outline; } + if (depth > 16) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } if (face.loca_length == 0 || face.glyf_length == 0) { opentype_outline_fail(&outline, OpenTypeError.unsupported_outline); return outline; } @@ -491,12 +506,6 @@ fn opentype_glyph_outline(face: OpenTypeFace, glyph_id: i32) GlyphOutline { let glyph_start: i32 = face.glyf_offset + start64 as i32; let glyph_end: i32 = face.glyf_offset + end64 as i32; let contour_count: i32 = opentype_i16(bytes, glyph_start); - if (contour_count < 0) { - opentype_outline_fail(&outline, OpenTypeError.unsupported_outline); return outline; - } - if (contour_count > 4096 || glyph_start + 10 + contour_count * 2 + 2 > glyph_end) { - opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; - } outline.x_min = opentype_i16(bytes, glyph_start + 2); outline.y_min = opentype_i16(bytes, glyph_start + 4); outline.x_max = opentype_i16(bytes, glyph_start + 6); @@ -504,6 +513,157 @@ fn opentype_glyph_outline(face: OpenTypeFace, glyph_id: i32) GlyphOutline { if (outline.x_min > outline.x_max || outline.y_min > outline.y_max) { opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; } + if (contour_count < 0) { + let cursor: i32 = glyph_start + 10; + let component_count: i32 = 0; + let more_components: i32 = 1; + let final_flags: i32 = 0; + while (more_components != 0) { + if (component_count >= 256 || cursor + 4 > glyph_end) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + let flags: i32 = opentype_u16(bytes, cursor); + let component_glyph: i32 = opentype_u16(bytes, cursor + 2); + cursor = cursor + 4; component_count = component_count + 1; + final_flags = flags; + if ((flags & 6144) == 6144 || (flags & (0 - 8192)) != 0) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + let arg_1: i32 = 0; + let arg_2: i32 = 0; + if ((flags & 1) != 0) { + if (cursor + 4 > glyph_end) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + if ((flags & 2) != 0) { + arg_1 = opentype_i16(bytes, cursor); + arg_2 = opentype_i16(bytes, cursor + 2); + } else { + arg_1 = opentype_u16(bytes, cursor); + arg_2 = opentype_u16(bytes, cursor + 2); + } + cursor = cursor + 4; + } else { + if (cursor + 2 > glyph_end) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + if ((flags & 2) != 0) { + arg_1 = opentype_i8(bytes[cursor]); arg_2 = opentype_i8(bytes[cursor + 1]); + } else { + arg_1 = bytes[cursor] as i32; arg_2 = bytes[cursor + 1] as i32; + } + cursor = cursor + 2; + } + let a: i32 = 16384; let b: i32 = 0; + let c: i32 = 0; let d: i32 = 16384; + let transform_kinds: i32 = (((flags & 8) != 0) as i32) + + (((flags & 64) != 0) as i32) + (((flags & 128) != 0) as i32); + if (transform_kinds > 1) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + if ((flags & 8) != 0) { + if (cursor + 2 > glyph_end) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + a = opentype_i16(bytes, cursor); d = a; cursor = cursor + 2; + } else if ((flags & 64) != 0) { + if (cursor + 4 > glyph_end) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + a = opentype_i16(bytes, cursor); d = opentype_i16(bytes, cursor + 2); + cursor = cursor + 4; + } else if ((flags & 128) != 0) { + if (cursor + 8 > glyph_end) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + a = opentype_i16(bytes, cursor); b = opentype_i16(bytes, cursor + 2); + c = opentype_i16(bytes, cursor + 4); d = opentype_i16(bytes, cursor + 6); + cursor = cursor + 8; + } + let component: GlyphOutline = opentype_glyph_outline_depth( + face, component_glyph, depth + 1); + if (component.error != OpenTypeError.none) { + opentype_glyph_outline_free(&component); + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + if (outline.points.len + component.points.len > 65535 || + outline.contour_ends.len + component.contour_ends.len > 4096) { + opentype_glyph_outline_free(&component); + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + let translate_x: i32 = arg_1; + let translate_y: i32 = arg_2; + if ((flags & 2) == 0) { + if (arg_1 < 0 || arg_1 >= outline.points.len || + arg_2 < 0 || arg_2 >= component.points.len) { + opentype_glyph_outline_free(&component); + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + let child_anchor: GlyphOutlinePoint = component.points.data[arg_2]; + let anchor_x: i32 = opentype_f2dot14_round( + (a as i64) * child_anchor.x + (c as i64) * child_anchor.y); + let anchor_y: i32 = opentype_f2dot14_round( + (b as i64) * child_anchor.x + (d as i64) * child_anchor.y); + translate_x = outline.points.data[arg_1].x - anchor_x; + translate_y = outline.points.data[arg_1].y - anchor_y; + } else if ((flags & 2048) != 0) { + let raw_x: i32 = translate_x; let raw_y: i32 = translate_y; + translate_x = opentype_f2dot14_round( + (a as i64) * raw_x + (c as i64) * raw_y); + translate_y = opentype_f2dot14_round( + (b as i64) * raw_x + (d as i64) * raw_y); + } + let point_base: i32 = outline.points.len; + let component_point: i32 = 0; + while (component_point < component.points.len) { + let source: GlyphOutlinePoint = component.points.data[component_point]; + let transformed_x: i64 = opentype_f2dot14_round( + (a as i64) * source.x + (c as i64) * source.y) as i64 + translate_x; + let transformed_y: i64 = opentype_f2dot14_round( + (b as i64) * source.x + (d as i64) * source.y) as i64 + translate_y; + if (transformed_x < (0 - 2147483647) || transformed_x > 2147483647 || + transformed_y < (0 - 2147483647) || transformed_y > 2147483647) { + opentype_glyph_outline_free(&component); + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + push[GlyphOutlinePoint](&outline.points, GlyphOutlinePoint{ + .x = transformed_x as i32, .y = transformed_y as i32, + .on_curve = source.on_curve }); + component_point = component_point + 1; + } + let component_contour: i32 = 0; + while (component_contour < component.contour_ends.len) { + push[i32](&outline.contour_ends, + point_base + component.contour_ends.data[component_contour]); + component_contour = component_contour + 1; + } + opentype_glyph_outline_free(&component); + more_components = ((flags & 32) != 0) as i32; + } + if ((final_flags & 256) != 0) { + if (cursor + 2 > glyph_end) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + let instruction_length: i32 = opentype_u16(bytes, cursor); + cursor = cursor + 2; + if (cursor + instruction_length > glyph_end) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + } + let composed_point: i32 = 0; + while (composed_point < outline.points.len) { + let point: GlyphOutlinePoint = outline.points.data[composed_point]; + if (point.x < outline.x_min || point.x > outline.x_max || + point.y < outline.y_min || point.y > outline.y_max) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } + composed_point = composed_point + 1; + } + return outline; + } + if (contour_count > 4096 || glyph_start + 10 + contour_count * 2 + 2 > glyph_end) { + opentype_outline_fail(&outline, OpenTypeError.invalid_outline); return outline; + } let cursor: i32 = glyph_start + 10; let contour_index: i32 = 0; let previous_end: i32 = 0 - 1; @@ -603,6 +763,10 @@ fn opentype_glyph_outline(face: OpenTypeFace, glyph_id: i32) GlyphOutline { return outline; } +fn opentype_glyph_outline(face: OpenTypeFace, glyph_id: i32) GlyphOutline { + return opentype_glyph_outline_depth(face, glyph_id, 0); +} + fn opentype_glyph_outline_free(outline: *GlyphOutline) void { free[GlyphOutlinePoint](&outline.*.points); free[i32](&outline.*.contour_ends); diff --git a/tests/opentype_contract.zag b/tests/opentype_contract.zag index 46c09c3..1ae7425 100644 --- a/tests/opentype_contract.zag +++ b/tests/opentype_contract.zag @@ -1,4 +1,5 @@ @import("../src/text/opentype.zag") +@import("support/opentype_fixture.zag") struct TestState { passed: i32, failed: i32 } @@ -12,76 +13,6 @@ fn expect(state: *TestState, condition: i32, name: []u8) void { } } -fn fixture_u16(bytes: *ArrayList[u8], offset: i32, value: i32) void { - bytes.*.data[offset] = ((value >> 8) & 255) as u8; - bytes.*.data[offset + 1] = (value & 255) as u8; -} - -fn fixture_u32(bytes: *ArrayList[u8], offset: i32, value: i64) void { - bytes.*.data[offset] = ((value >> 24) & 255) as u8; - bytes.*.data[offset + 1] = ((value >> 16) & 255) as u8; - bytes.*.data[offset + 2] = ((value >> 8) & 255) as u8; - bytes.*.data[offset + 3] = (value & 255) as u8; -} - -fn fixture_tag(bytes: *ArrayList[u8], offset: i32, tag: []u8) void { - let i: i32 = 0; - while (i < 4) { bytes.*.data[offset + i] = tag[i]; i = i + 1; } -} - -fn fixture_font() ArrayList[u8] { - let bytes: ArrayList[u8] = make[u8](360); - let i: i32 = 0; - while (i < 360) { push[u8](&bytes, 0); i = i + 1; } - fixture_u32(&bytes, 0, 65536); fixture_u16(&bytes, 4, 7); - fixture_tag(&bytes, 12, "cmap"); fixture_u32(&bytes, 20, 128); fixture_u32(&bytes, 24, 44); - fixture_tag(&bytes, 28, "head"); fixture_u32(&bytes, 36, 176); fixture_u32(&bytes, 40, 54); - fixture_tag(&bytes, 44, "maxp"); fixture_u32(&bytes, 52, 232); fixture_u32(&bytes, 56, 6); - fixture_tag(&bytes, 60, "hhea"); fixture_u32(&bytes, 68, 240); fixture_u32(&bytes, 72, 36); - fixture_tag(&bytes, 76, "hmtx"); fixture_u32(&bytes, 84, 276); fixture_u32(&bytes, 88, 40); - fixture_tag(&bytes, 92, "loca"); fixture_u32(&bytes, 100, 316); fixture_u32(&bytes, 104, 22); - fixture_tag(&bytes, 108, "glyf"); fixture_u32(&bytes, 116, 340); fixture_u32(&bytes, 120, 20); - fixture_u16(&bytes, 128, 0); fixture_u16(&bytes, 130, 1); - fixture_u16(&bytes, 132, 3); fixture_u16(&bytes, 134, 1); fixture_u32(&bytes, 136, 12); - fixture_u16(&bytes, 140, 4); fixture_u16(&bytes, 142, 32); - fixture_u16(&bytes, 144, 0); fixture_u16(&bytes, 146, 4); - fixture_u16(&bytes, 148, 4); fixture_u16(&bytes, 150, 1); fixture_u16(&bytes, 152, 0); - fixture_u16(&bytes, 154, 65); fixture_u16(&bytes, 156, 65535); fixture_u16(&bytes, 158, 0); - fixture_u16(&bytes, 160, 65); fixture_u16(&bytes, 162, 65535); - fixture_u16(&bytes, 164, 65474); fixture_u16(&bytes, 166, 1); - fixture_u16(&bytes, 168, 0); fixture_u16(&bytes, 170, 0); - fixture_u16(&bytes, 194, 1000); - fixture_u16(&bytes, 226, 0); - fixture_u32(&bytes, 232, 65536); fixture_u16(&bytes, 236, 10); - fixture_u16(&bytes, 274, 10); - let metric: i32 = 0; - while (metric < 10) { - fixture_u16(&bytes, 276 + metric * 4, 500 + metric * 10); - fixture_u16(&bytes, 278 + metric * 4, 0); - metric = metric + 1; - } - let location: i32 = 4; - while (location <= 10) { fixture_u16(&bytes, 316 + location * 2, 10); location = location + 1; } - fixture_u16(&bytes, 340, 1); - fixture_u16(&bytes, 342, 0); fixture_u16(&bytes, 344, 0); - fixture_u16(&bytes, 346, 100); fixture_u16(&bytes, 348, 100); - fixture_u16(&bytes, 350, 2); fixture_u16(&bytes, 352, 0); - bytes.data[354] = 49; bytes.data[355] = 51; bytes.data[356] = 39; - bytes.data[357] = 100; bytes.data[358] = 100; bytes.data[359] = 100; - return bytes; -} - -fn fixture_font_format12() ArrayList[u8] { - let bytes: ArrayList[u8] = fixture_font(); - fixture_u16(&bytes, 134, 10); - fixture_u16(&bytes, 140, 12); fixture_u16(&bytes, 142, 0); - fixture_u32(&bytes, 144, 28); fixture_u32(&bytes, 148, 0); - fixture_u32(&bytes, 152, 1); - fixture_u32(&bytes, 156, 128512); fixture_u32(&bytes, 160, 128512); - fixture_u32(&bytes, 164, 4); - return bytes; -} - fn parse_contract(state: *TestState) void { let bytes: ArrayList[u8] = fixture_font(); let face: OpenTypeFace = opentype_parse(bytes.data[0..bytes.len]); @@ -106,13 +37,44 @@ fn parse_contract(state: *TestState) void { let outline: GlyphOutline = opentype_glyph_outline(face, 3); expect(state, outline.error == OpenTypeError.none && outline.points.len == 3 && outline.contour_ends.len == 1 && outline.contour_ends.data[0] == 2 && - outline.points.data[0].x == 0 && outline.points.data[0].y == 0 && - outline.points.data[1].x == 100 && outline.points.data[1].y == 0 && - outline.points.data[2].x == 0 && outline.points.data[2].y == 100, - "simple glyf contours decode exact on-curve font-unit points"); + outline.points.data[0].x == 10 && outline.points.data[0].y == 20 && + outline.points.data[1].x == 110 && outline.points.data[1].y == 20 && + outline.points.data[2].x == 10 && outline.points.data[2].y == 120, + "composite glyf components decode exact translated contour points"); opentype_glyph_outline_free(&outline); + let simple_outline: GlyphOutline = opentype_glyph_outline(face, 4); + expect(state, simple_outline.error == OpenTypeError.none && + simple_outline.points.len == 3 && simple_outline.points.data[1].x == 100, + "simple glyf contours remain directly addressable components"); + opentype_glyph_outline_free(&simple_outline); opentype_face_free(&face); free[u8](&bytes); + let scaled_bytes: ArrayList[u8] = fixture_font(); + fixture_u16(&scaled_bytes, 346, 60); fixture_u16(&scaled_bytes, 348, 70); + fixture_u16(&scaled_bytes, 350, 11); fixture_u16(&scaled_bytes, 358, 8192); + let scaled_face: OpenTypeFace = opentype_parse( + scaled_bytes.data[0..scaled_bytes.len]); + let scaled_outline: GlyphOutline = opentype_glyph_outline(scaled_face, 3); + expect(state, scaled_outline.error == OpenTypeError.none && + scaled_outline.points.len == 3 && scaled_outline.points.data[1].x == 60 && + scaled_outline.points.data[2].y == 70, + "F2Dot14 composite scale applies before exact component translation"); + opentype_glyph_outline_free(&scaled_outline); + opentype_face_free(&scaled_face); free[u8](&scaled_bytes); + + let attached_bytes: ArrayList[u8] = fixture_font_point_attached(); + let attached_face: OpenTypeFace = opentype_parse( + attached_bytes.data[0..attached_bytes.len]); + let attached_outline: GlyphOutline = opentype_glyph_outline(attached_face, 3); + expect(state, attached_outline.error == OpenTypeError.none && + attached_outline.points.len == 6 && attached_outline.contour_ends.len == 2 && + attached_outline.points.data[1].x == 100 && + attached_outline.points.data[3].x == 100 && + attached_outline.points.data[4].x == 200, + "point-number attachment aligns a later component to retained parent geometry"); + opentype_glyph_outline_free(&attached_outline); + opentype_face_free(&attached_face); free[u8](&attached_bytes); + let format12_bytes: ArrayList[u8] = fixture_font_format12(); let format12_face: OpenTypeFace = opentype_parse( format12_bytes.data[0..format12_bytes.len]); @@ -165,15 +127,15 @@ fn malformed_contract(state: *TestState) void { opentype_glyph_outline_free(&escaped_outline); opentype_face_free(&escaped_loca_face); free[u8](&escaped_loca_bytes); - let composite_bytes: ArrayList[u8] = fixture_font(); - fixture_u16(&composite_bytes, 340, 65535); - let composite_face: OpenTypeFace = opentype_parse( - composite_bytes.data[0..composite_bytes.len]); - let composite_outline: GlyphOutline = opentype_glyph_outline(composite_face, 3); - expect(state, composite_outline.error == OpenTypeError.unsupported_outline, - "composite glyphs remain explicitly unsupported until owned decoding exists"); - opentype_glyph_outline_free(&composite_outline); - opentype_face_free(&composite_face); free[u8](&composite_bytes); + let recursive_bytes: ArrayList[u8] = fixture_font(); + fixture_u16(&recursive_bytes, 352, 3); + let recursive_face: OpenTypeFace = opentype_parse( + recursive_bytes.data[0..recursive_bytes.len]); + let recursive_outline: GlyphOutline = opentype_glyph_outline(recursive_face, 3); + expect(state, recursive_outline.error == OpenTypeError.invalid_outline, + "recursive composite glyphs hit a bounded fail-closed depth"); + opentype_glyph_outline_free(&recursive_outline); + opentype_face_free(&recursive_face); free[u8](&recursive_bytes); } fn main() i32 { diff --git a/tests/opentype_fuzz.zag b/tests/opentype_fuzz.zag index 181e122..1e72353 100644 --- a/tests/opentype_fuzz.zag +++ b/tests/opentype_fuzz.zag @@ -1,10 +1,35 @@ @import("../src/text/opentype.zag") +@import("support/opentype_fixture.zag") fn fuzz_next(state: *i64) i64 { state.* = (state.* * 48271) % 2147483647; return state.*; } +fn fuzz_outline_valid(outline: GlyphOutline) i32 { + if (outline.error != OpenTypeError.none) { return 1; } + if (outline.points.len > 65535 || outline.contour_ends.len > 4096) { return 0; } + if (outline.contour_ends.len == 0) { return (outline.points.len == 0) as i32; } + if (outline.contour_ends.data[outline.contour_ends.len - 1] != + outline.points.len - 1) { return 0; } + let previous: i32 = 0 - 1; + let contour: i32 = 0; + while (contour < outline.contour_ends.len) { + let endpoint: i32 = outline.contour_ends.data[contour]; + if (endpoint <= previous || endpoint >= outline.points.len) { return 0; } + previous = endpoint; contour = contour + 1; + } + let point: i32 = 0; + while (point < outline.points.len) { + let value: GlyphOutlinePoint = outline.points.data[point]; + if (value.on_curve < 0 || value.on_curve > 1 || + value.x < outline.x_min || value.x > outline.x_max || + value.y < outline.y_min || value.y > outline.y_max) { return 0; } + point = point + 1; + } + return 1; +} + fn main() i32 { let seed: i64 = 918273; let iteration: i32 = 0; @@ -40,6 +65,31 @@ fn main() i32 { free[u8](&bytes); iteration = iteration + 1; } - _zag_println("OpenType fuzz: pass=20000 fail=0"); + iteration = 0; + while (iteration < 20000) { + let bytes: ArrayList[u8] = fixture_font(); + let mutation_count: i32 = 1 + (fuzz_next(&seed) % 4) as i32; + let mutation: i32 = 0; + while (mutation < mutation_count) { + let offset: i32 = (fuzz_next(&seed) % bytes.len) as i32; + bytes.data[offset] = (fuzz_next(&seed) & 255) as u8; + mutation = mutation + 1; + } + let face: OpenTypeFace = opentype_parse(bytes.data[0..bytes.len]); + if (face.error == OpenTypeError.none && face.glyph_count > 3 && + face.loca_length > 0) { + let outline: GlyphOutline = opentype_glyph_outline(face, 3); + if (fuzz_outline_valid(outline) == 0) { + opentype_glyph_outline_free(&outline); + opentype_face_free(&face); free[u8](&bytes); + _zag_eprintln("OpenType fuzz: structured mutation escaped outline invariants"); + return 3; + } + opentype_glyph_outline_free(&outline); + } + opentype_face_free(&face); free[u8](&bytes); + iteration = iteration + 1; + } + _zag_println("OpenType fuzz: arbitrary=20000 structured=20000 fail=0"); return 0; } diff --git a/tests/support/opentype_fixture.zag b/tests/support/opentype_fixture.zag new file mode 100644 index 0000000..3ac1bcb --- /dev/null +++ b/tests/support/opentype_fixture.zag @@ -0,0 +1,98 @@ +fn fixture_u16(bytes: *ArrayList[u8], offset: i32, value: i32) void { + bytes.*.data[offset] = ((value >> 8) & 255) as u8; + bytes.*.data[offset + 1] = (value & 255) as u8; +} + +fn fixture_u32(bytes: *ArrayList[u8], offset: i32, value: i64) void { + bytes.*.data[offset] = ((value >> 24) & 255) as u8; + bytes.*.data[offset + 1] = ((value >> 16) & 255) as u8; + bytes.*.data[offset + 2] = ((value >> 8) & 255) as u8; + bytes.*.data[offset + 3] = (value & 255) as u8; +} + +fn fixture_tag(bytes: *ArrayList[u8], offset: i32, tag: []u8) void { + let i: i32 = 0; + while (i < 4) { bytes.*.data[offset + i] = tag[i]; i = i + 1; } +} + +fn fixture_font() ArrayList[u8] { + let bytes: ArrayList[u8] = make[u8](380); + let i: i32 = 0; + while (i < 380) { push[u8](&bytes, 0); i = i + 1; } + fixture_u32(&bytes, 0, 65536); fixture_u16(&bytes, 4, 7); + fixture_tag(&bytes, 12, "cmap"); fixture_u32(&bytes, 20, 128); fixture_u32(&bytes, 24, 44); + fixture_tag(&bytes, 28, "head"); fixture_u32(&bytes, 36, 176); fixture_u32(&bytes, 40, 54); + fixture_tag(&bytes, 44, "maxp"); fixture_u32(&bytes, 52, 232); fixture_u32(&bytes, 56, 6); + fixture_tag(&bytes, 60, "hhea"); fixture_u32(&bytes, 68, 240); fixture_u32(&bytes, 72, 36); + fixture_tag(&bytes, 76, "hmtx"); fixture_u32(&bytes, 84, 276); fixture_u32(&bytes, 88, 40); + fixture_tag(&bytes, 92, "loca"); fixture_u32(&bytes, 100, 316); fixture_u32(&bytes, 104, 22); + fixture_tag(&bytes, 108, "glyf"); fixture_u32(&bytes, 116, 340); fixture_u32(&bytes, 120, 40); + fixture_u16(&bytes, 128, 0); fixture_u16(&bytes, 130, 1); + fixture_u16(&bytes, 132, 3); fixture_u16(&bytes, 134, 1); fixture_u32(&bytes, 136, 12); + fixture_u16(&bytes, 140, 4); fixture_u16(&bytes, 142, 32); + fixture_u16(&bytes, 144, 0); fixture_u16(&bytes, 146, 4); + fixture_u16(&bytes, 148, 4); fixture_u16(&bytes, 150, 1); fixture_u16(&bytes, 152, 0); + fixture_u16(&bytes, 154, 65); fixture_u16(&bytes, 156, 65535); fixture_u16(&bytes, 158, 0); + fixture_u16(&bytes, 160, 65); fixture_u16(&bytes, 162, 65535); + fixture_u16(&bytes, 164, 65474); fixture_u16(&bytes, 166, 1); + fixture_u16(&bytes, 168, 0); fixture_u16(&bytes, 170, 0); + fixture_u16(&bytes, 194, 1000); fixture_u16(&bytes, 226, 0); + fixture_u32(&bytes, 232, 65536); fixture_u16(&bytes, 236, 10); + fixture_u16(&bytes, 274, 10); + let metric: i32 = 0; + while (metric < 10) { + fixture_u16(&bytes, 276 + metric * 4, 500 + metric * 10); + fixture_u16(&bytes, 278 + metric * 4, 0); metric = metric + 1; + } + fixture_u16(&bytes, 324, 10); + let location: i32 = 5; + while (location <= 10) { + fixture_u16(&bytes, 316 + location * 2, 20); location = location + 1; + } + fixture_u16(&bytes, 340, 65535); + fixture_u16(&bytes, 342, 10); fixture_u16(&bytes, 344, 20); + fixture_u16(&bytes, 346, 110); fixture_u16(&bytes, 348, 120); + fixture_u16(&bytes, 350, 3); fixture_u16(&bytes, 352, 4); + fixture_u16(&bytes, 354, 10); fixture_u16(&bytes, 356, 20); + fixture_u16(&bytes, 360, 1); + fixture_u16(&bytes, 362, 0); fixture_u16(&bytes, 364, 0); + fixture_u16(&bytes, 366, 100); fixture_u16(&bytes, 368, 100); + fixture_u16(&bytes, 370, 2); fixture_u16(&bytes, 372, 0); + bytes.data[374] = 49; bytes.data[375] = 51; bytes.data[376] = 39; + bytes.data[377] = 100; bytes.data[378] = 100; bytes.data[379] = 100; + return bytes; +} + +fn fixture_font_format12() ArrayList[u8] { + let bytes: ArrayList[u8] = fixture_font(); + fixture_u16(&bytes, 134, 10); + fixture_u16(&bytes, 140, 12); fixture_u16(&bytes, 142, 0); + fixture_u32(&bytes, 144, 28); fixture_u32(&bytes, 148, 0); + fixture_u32(&bytes, 152, 1); + fixture_u32(&bytes, 156, 128512); fixture_u32(&bytes, 160, 128512); + fixture_u32(&bytes, 164, 4); + return bytes; +} + +fn fixture_font_point_attached() ArrayList[u8] { + let bytes: ArrayList[u8] = fixture_font(); + while (bytes.len < 400) { push[u8](&bytes, 0); } + let copy: i32 = 0; + while (copy < 20) { + bytes.data[380 + copy] = bytes.data[360 + copy]; + bytes.data[360 + copy] = 0; copy = copy + 1; + } + fixture_u32(&bytes, 120, 60); + fixture_u16(&bytes, 324, 20); + let location: i32 = 5; + while (location <= 10) { + fixture_u16(&bytes, 316 + location * 2, 30); location = location + 1; + } + fixture_u16(&bytes, 342, 0); fixture_u16(&bytes, 344, 0); + fixture_u16(&bytes, 346, 200); fixture_u16(&bytes, 348, 100); + fixture_u16(&bytes, 350, 35); fixture_u16(&bytes, 352, 4); + fixture_u16(&bytes, 354, 0); fixture_u16(&bytes, 356, 0); + fixture_u16(&bytes, 358, 1); fixture_u16(&bytes, 360, 4); + fixture_u16(&bytes, 362, 1); fixture_u16(&bytes, 364, 0); + return bytes; +} diff --git a/tools/test-system-font.zag b/tools/test-system-font.zag index e12b84e..1a1f99c 100644 --- a/tools/test-system-font.zag +++ b/tools/test-system-font.zag @@ -60,6 +60,22 @@ fn main() i32 { } index = index + 1; } + let accented_lookup: GlyphLookup = opentype_glyph_for_scalar(face, 233); + if (accented_lookup.error != OpenTypeError.none || accented_lookup.glyph_id == 0) { + utf8_decode_free(&text); opentype_face_free(&face); + _zag_eprintln("system font smoke: accented Latin glyph is unavailable"); + return 9; + } + let accented_outline: GlyphOutline = opentype_glyph_outline( + face, accented_lookup.glyph_id); + if (accented_outline.error != OpenTypeError.none || accented_outline.points.len == 0) { + opentype_glyph_outline_free(&accented_outline); + utf8_decode_free(&text); opentype_face_free(&face); + _zag_eprintln("system font smoke: accented Latin outline failed"); + return 10; + } + let accented_points: i32 = accented_outline.points.len; + opentype_glyph_outline_free(&accented_outline); _zag_print("system font smoke: unitsPerEm="); _zag_print_i64(face.units_per_em as i64); _zag_print(" glyphs="); @@ -70,6 +86,8 @@ fn main() i32 { _zag_print_i64(outline_points as i64); _zag_print(" first_path_commands="); _zag_print_i64(path_commands as i64); + _zag_print(" accented_outline_points="); + _zag_print_i64(accented_points as i64); _zag_println(""); utf8_decode_free(&text); opentype_face_free(&face); return 0; From fa4a4e4a92cd57ce621c52c52fad3634a2f813a8 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 02:00:33 -0700 Subject: [PATCH 079/103] Assemble nominal positioned glyph runs --- docs/architecture/text.md | 14 ++- src/text/glyph_run.zag | 162 +++++++++++++++++++++++++++++++++++ tests/glyph_run_contract.zag | 64 ++++++++++++++ tools/test-headless.sh | 2 + tools/test-system-font.zag | 20 ++++- 5 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 src/text/glyph_run.zag create mode 100644 tests/glyph_run_contract.zag diff --git a/docs/architecture/text.md b/docs/architecture/text.md index d7575ec..7c71376 100644 --- a/docs/architecture/text.md +++ b/docs/architecture/text.md @@ -29,7 +29,12 @@ native widget text engines are not runtime dependencies. quadratic Zagkit paths at an exact fixed-point font size and baseline, including implied midpoints between consecutive off-curve points. - Those canonical paths already execute through the deterministic CPU oracle; - positioned multi-glyph run assembly and text antialiasing policy remain open. + the shaped-run resource and text antialiasing policy remain open. +- `src/text/glyph_run.zag` provides an explicitly nominal LTR stage: strict + UTF-8 cluster byte spans map to glyph IDs, cumulative `hmtx` advances produce + drift-free fixed-point origins, and the positioned contours assemble into + one immutable path executable by the CPU oracle. Missing coverage returns a + fallback request instead of a fabricated glyph. - Unicode-to-glyph lookup supports bounded `cmap` format 4 and format 12 subtables. A missing mapping returns glyph zero; it does not invent fallback. - The parser rejects table escapes, duplicate required tables, malformed @@ -41,11 +46,12 @@ native widget text engines are not runtime dependencies. 1. Parse vertical metrics, CFF/CFF2 outlines, variation axes, color-glyph tables, and font metadata. 2. Implement normalization, grapheme/word/sentence segmentation, script runs, - bidi resolution, line breaking, fallback, and locale-aware shaping. + bidi resolution, line breaking, fallback, and locale-aware shaping. The + nominal LTR stage is not a substitute for any of these operations. 3. Implement OpenType GSUB/GPOS and variation application with deterministic glyph-run serialization. -4. Assemble and rasterize positioned glyph runs in the CPU oracle with - subpixel antialiasing and documented color-space behavior. +4. Promote shaped glyph runs to a canonical render resource and define the + subpixel antialiasing and color-space policy used by the CPU oracle. 5. Connect glyph runs to intrinsic measurement, selection, editing, semantics, IME composition, accessibility text navigation, Talkback, and replay. 6. Prove representative Latin, Arabic, Hebrew, Indic, CJK, emoji, combining, diff --git a/src/text/glyph_run.zag b/src/text/glyph_run.zag new file mode 100644 index 0000000..adf055f --- /dev/null +++ b/src/text/glyph_run.zag @@ -0,0 +1,162 @@ +@import("unicode.zag") +@import("glyph_path.zag") + +enum GlyphRunError { + none, + invalid_text, + invalid_metrics, + missing_glyph, + invalid_outline, + path_error, + too_many_glyphs, +} + +struct PositionedGlyph { + glyph_id: i32, + cluster_byte_offset: i32, + cluster_byte_length: i32, + origin_x: i64, + origin_y: i64, + advance_x: i64, +} + +struct NominalGlyphRun { + glyphs: ArrayList[PositionedGlyph], + utf8: ArrayList[u8], + font_size: i64, + total_advance: i64, + error: GlyphRunError, + error_index: i32, +} + +struct GlyphRunPathResult { + path: PathData, + error: GlyphRunError, + error_index: i32, +} + +fn nominal_glyph_run_empty(font_size: i64) NominalGlyphRun { + return NominalGlyphRun{ + .glyphs = make[PositionedGlyph](8), .utf8 = make[u8](16), + .font_size = font_size, .total_advance = 0, + .error = GlyphRunError.none, .error_index = 0 - 1, + }; +} + +fn nominal_glyph_run_free(run: *NominalGlyphRun) void { + free[PositionedGlyph](&run.*.glyphs); free[u8](&run.*.utf8); +} + +fn nominal_glyph_run_ltr(face: OpenTypeFace, text: []u8, font_size: i64, + origin_x: i64, baseline_y: i64) NominalGlyphRun { + let run: NominalGlyphRun = nominal_glyph_run_empty(font_size); + if (face.error != OpenTypeError.none || face.units_per_em <= 0 || + font_size <= 0 || path_fixed_valid(font_size) == 0 || + path_fixed_valid(origin_x) == 0 || path_fixed_valid(baseline_y) == 0) { + run.error = GlyphRunError.invalid_metrics; return run; + } + let byte_index: i32 = 0; + while (byte_index < text.len) { + push[u8](&run.utf8, text[byte_index]); byte_index = byte_index + 1; + } + let decoded: Utf8DecodeResult = utf8_decode(text); + if (decoded.error != Utf8Error.none) { + utf8_decode_free(&decoded); run.error = GlyphRunError.invalid_text; return run; + } + if (decoded.scalars.len > 1048576) { + utf8_decode_free(&decoded); run.error = GlyphRunError.too_many_glyphs; return run; + } + let accumulated_units: i64 = 0; + let scalar_index: i32 = 0; + while (scalar_index < decoded.scalars.len) { + let scalar: UnicodeScalar = decoded.scalars.data[scalar_index]; + let lookup: GlyphLookup = opentype_glyph_for_scalar(face, scalar.value); + if (lookup.error != OpenTypeError.none || lookup.glyph_id == 0) { + run.error = GlyphRunError.missing_glyph; run.error_index = scalar_index; + utf8_decode_free(&decoded); return run; + } + let metric: GlyphHorizontalMetric = opentype_horizontal_metric(face, lookup.glyph_id); + if (metric.error != OpenTypeError.none) { + run.error = GlyphRunError.invalid_metrics; run.error_index = scalar_index; + utf8_decode_free(&decoded); return run; + } + let position: i64 = origin_x + + (accumulated_units * font_size) / (face.units_per_em as i64); + let next_units: i64 = accumulated_units + metric.advance_width; + let next_position: i64 = origin_x + + (next_units * font_size) / (face.units_per_em as i64); + if (path_fixed_valid(position) == 0 || path_fixed_valid(next_position) == 0) { + run.error = GlyphRunError.invalid_metrics; run.error_index = scalar_index; + utf8_decode_free(&decoded); return run; + } + push[PositionedGlyph](&run.glyphs, PositionedGlyph{ + .glyph_id = lookup.glyph_id, + .cluster_byte_offset = scalar.byte_start as i32, + .cluster_byte_length = scalar.byte_length, + .origin_x = position, .origin_y = baseline_y, + .advance_x = next_position - position, + }); + accumulated_units = next_units; scalar_index = scalar_index + 1; + } + run.total_advance = (accumulated_units * font_size) / + (face.units_per_em as i64); + utf8_decode_free(&decoded); + return run; +} + +fn glyph_run_path_result() GlyphRunPathResult { + return GlyphRunPathResult{ + .path = path_make(PathFillRule.non_zero), .error = GlyphRunError.none, + .error_index = 0 - 1, + }; +} + +fn nominal_glyph_run_path(face: OpenTypeFace, + run: NominalGlyphRun) GlyphRunPathResult { + let result: GlyphRunPathResult = glyph_run_path_result(); + if (run.error != GlyphRunError.none || face.error != OpenTypeError.none) { + result.error = run.error; + if (result.error == GlyphRunError.none) { result.error = GlyphRunError.invalid_metrics; } + return result; + } + let glyph_index: i32 = 0; + while (glyph_index < run.glyphs.len) { + let glyph: PositionedGlyph = run.glyphs.data[glyph_index]; + let outline: GlyphOutline = opentype_glyph_outline(face, glyph.glyph_id); + if (outline.error != OpenTypeError.none) { + opentype_glyph_outline_free(&outline); + result.error = GlyphRunError.invalid_outline; result.error_index = glyph_index; + return result; + } + if (outline.points.len > 0) { + let glyph_path: GlyphPathResult = glyph_outline_to_path(outline, + face.units_per_em, run.font_size, glyph.origin_x, glyph.origin_y); + if (glyph_path.error != GlyphPathError.none) { + glyph_path_result_free(&glyph_path); opentype_glyph_outline_free(&outline); + result.error = GlyphRunError.path_error; result.error_index = glyph_index; + return result; + } + let command_index: i32 = 0; + while (command_index < glyph_path.path.commands.len) { + if (path_push(&result.path, glyph_path.path.commands.data[command_index]) != + PathError.none) { + glyph_path_result_free(&glyph_path); opentype_glyph_outline_free(&outline); + result.error = GlyphRunError.path_error; result.error_index = glyph_index; + return result; + } + command_index = command_index + 1; + } + glyph_path_result_free(&glyph_path); + } + opentype_glyph_outline_free(&outline); + glyph_index = glyph_index + 1; + } + if (result.path.commands.len == 0 || path_seal(&result.path) != PathError.none) { + result.error = GlyphRunError.path_error; + } + return result; +} + +fn glyph_run_path_result_free(result: *GlyphRunPathResult) void { + path_free(&result.*.path); +} diff --git a/tests/glyph_run_contract.zag b/tests/glyph_run_contract.zag new file mode 100644 index 0000000..515d75a --- /dev/null +++ b/tests/glyph_run_contract.zag @@ -0,0 +1,64 @@ +@import("../src/text/glyph_run.zag") +@import("../src/render/cpu_raster.zag") +@import("support/opentype_fixture.zag") + +struct TestState { passed: i32, failed: i32 } +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; _zag_print("FAIL "); _zag_println(name); + } +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + let bytes: ArrayList[u8] = fixture_font(); + let face: OpenTypeFace = opentype_parse(bytes.data[0..bytes.len]); + let run: NominalGlyphRun = nominal_glyph_run_ltr( + face, "AAA", 10 * unit_scale(), unit_scale(), 8 * unit_scale()); + expect(&state, run.error == GlyphRunError.none && run.glyphs.len == 3 && + run.glyphs.data[0].glyph_id == 3 && run.glyphs.data[2].cluster_byte_offset == 2, + "nominal mapping retains exact glyph IDs and UTF-8 cluster spans"); + let first_advance: i64 = (530 * 10 * unit_scale()) / 1000; + let second_origin: i64 = (1060 * 10 * unit_scale()) / 1000; + let complete_advance: i64 = (1590 * 10 * unit_scale()) / 1000; + expect(&state, run.glyphs.data[0].origin_x == unit_scale() && + run.glyphs.data[1].origin_x == unit_scale() + first_advance && + run.glyphs.data[2].origin_x == unit_scale() + second_origin && + run.total_advance == complete_advance, + "cumulative font-unit positioning conserves exact fixed-point advance"); + let run_path: GlyphRunPathResult = nominal_glyph_run_path(face, run); + expect(&state, run_path.error == GlyphRunError.none && + path_verify(run_path.path) == PathError.none && run_path.path.commands.len == 12, + "positioned glyph contours assemble into one canonical retained path"); + let payload: PathEncodeResult = path_encode(run_path.path); + let list: DisplayList = display_list_make(); + _ = display_list_add_resource(&list, render_resource_spec( + 77, RenderResourceKind.path, payload.bytes.data[0..payload.bytes.len], + path_resource_format_tag())); + path_encode_free(&payload); + let draw: DisplayOp = display_op(DisplayOpKind.draw_path, node_key(77)); + draw.resource_id = 77; draw.paint = paint_rgba16(65535, 65535, 65535, 65535); + _ = display_list_push(&list, draw); _ = display_list_seal(&list); + let raster: CpuRasterResult = cpu_rasterize(list, 24, 10); + expect(&state, raster.error == CpuRasterError.none && raster.pixels_touched > 0, + "a positioned multi-glyph run executes through the CPU oracle"); + cpu_raster_free(&raster); display_list_free(&list); + glyph_run_path_result_free(&run_path); nominal_glyph_run_free(&run); + + let missing: NominalGlyphRun = nominal_glyph_run_ltr(face, "B", unit_scale(), 0, 0); + expect(&state, missing.error == GlyphRunError.missing_glyph && missing.error_index == 0, + "missing font coverage requests fallback instead of inventing a glyph"); + nominal_glyph_run_free(&missing); + let malformed: NominalGlyphRun = nominal_glyph_run_ltr( + face, "\xC0\xAF", unit_scale(), 0, 0); + expect(&state, malformed.error == GlyphRunError.invalid_text, + "nominal runs reject malformed UTF-8 before glyph lookup"); + nominal_glyph_run_free(&malformed); + opentype_face_free(&face); free[u8](&bytes); + _zag_print("Glyph run contract: pass="); _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 5ac7ea2..46aa735 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -24,6 +24,8 @@ cd "$root" "$tmp/opentype-fuzz" "$znc" tests/glyph_path_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/glyph-path-contract" "$tmp/glyph-path-contract" +"$znc" tests/glyph_run_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/glyph-run-contract" +"$tmp/glyph-run-contract" "$znc" tests/flex_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/flex-contract" "$tmp/flex-contract" "$znc" tests/flex_adaptive_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/flex-adaptive-contract" diff --git a/tools/test-system-font.zag b/tools/test-system-font.zag index 1a1f99c..529b415 100644 --- a/tools/test-system-font.zag +++ b/tools/test-system-font.zag @@ -1,5 +1,5 @@ @import("../src/text/unicode.zag") -@import("../src/text/glyph_path.zag") +@import("../src/text/glyph_run.zag") fn main() i32 { if (_zag_argc() != 2) { @@ -76,6 +76,20 @@ fn main() i32 { } let accented_points: i32 = accented_outline.points.len; opentype_glyph_outline_free(&accented_outline); + let nominal_run: NominalGlyphRun = nominal_glyph_run_ltr( + face, "Zagkit", 24 * unit_scale(), 0, 24 * unit_scale()); + let nominal_path: GlyphRunPathResult = nominal_glyph_run_path(face, nominal_run); + if (nominal_run.error != GlyphRunError.none || + nominal_path.error != GlyphRunError.none || + path_verify(nominal_path.path) != PathError.none) { + glyph_run_path_result_free(&nominal_path); nominal_glyph_run_free(&nominal_run); + utf8_decode_free(&text); opentype_face_free(&face); + _zag_eprintln("system font smoke: nominal Latin run failed"); + return 11; + } + let nominal_commands: i32 = nominal_path.path.commands.len; + let nominal_advance: i64 = nominal_run.total_advance; + glyph_run_path_result_free(&nominal_path); nominal_glyph_run_free(&nominal_run); _zag_print("system font smoke: unitsPerEm="); _zag_print_i64(face.units_per_em as i64); _zag_print(" glyphs="); @@ -88,6 +102,10 @@ fn main() i32 { _zag_print_i64(path_commands as i64); _zag_print(" accented_outline_points="); _zag_print_i64(accented_points as i64); + _zag_print(" nominal_run_commands="); + _zag_print_i64(nominal_commands as i64); + _zag_print(" nominal_advance_fixed="); + _zag_print_i64(nominal_advance); _zag_println(""); utf8_decode_free(&text); opentype_face_free(&face); return 0; From d99a3b65475450e837ba64c64d7c6f0fb427761d Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 02:09:52 -0700 Subject: [PATCH 080/103] Render real typography through scanline CPU paths --- docs/architecture/text.md | 3 + docs/evidence/linux-typography-2026-08-09.md | 47 ++++++++ src/render/cpu_raster.zag | 90 +++++++++++--- tests/cpu_raster_contract.zag | 24 ++++ tools/render-system-font-reference.sh | 22 ++++ tools/render-system-font-reference.zag | 118 +++++++++++++++++++ 6 files changed, 290 insertions(+), 14 deletions(-) create mode 100644 docs/evidence/linux-typography-2026-08-09.md create mode 100755 tools/render-system-font-reference.sh create mode 100644 tools/render-system-font-reference.zag diff --git a/docs/architecture/text.md b/docs/architecture/text.md index 7c71376..98392fb 100644 --- a/docs/architecture/text.md +++ b/docs/architecture/text.md @@ -35,6 +35,9 @@ native widget text engines are not runtime dependencies. drift-free fixed-point origins, and the positioned contours assemble into one immutable path executable by the CPU oracle. Missing coverage returns a fallback request instead of a fabricated glyph. +- The CPU path oracle indexes active edges per subpixel scanline, preserving + exact eight-by-eight coverage while avoiding all-edges-per-pixel sentence + work. It preflights the actual bounded scanline work before touching pixels. - Unicode-to-glyph lookup supports bounded `cmap` format 4 and format 12 subtables. A missing mapping returns glyph zero; it does not invent fallback. - The parser rejects table escapes, duplicate required tables, malformed diff --git a/docs/evidence/linux-typography-2026-08-09.md b/docs/evidence/linux-typography-2026-08-09.md new file mode 100644 index 0000000..fca8406 --- /dev/null +++ b/docs/evidence/linux-typography-2026-08-09.md @@ -0,0 +1,47 @@ +# Linux typography CPU evidence — 2026-08-09 + +Status: **owned nominal Latin rendering proof**, not international shaping or a +Linux typography-completion claim. + +## Proven path + +`tools/render-system-font-reference.sh` resolves an explicit system font, +compiles the renderer in Zag, and generates +`artifacts/evidence/linux-typography-cpu-oracle.png` without FreeType, +HarfBuzz, Skia, browser text, or a native widget renderer. + +For the local proof: + +- Font: `/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf` +- Font SHA-256: + `89c3c497f618fdaa0b2d1e98fef93582f28c71debd2c4a8cdf41f190ced2909d` +- Output: 960×320 RGBA PNG +- Output SHA-256: + `12b8ff5716e8f34da87ea7156addf9aaf6bd1ddd9e7e2a3c2400f00fc2be2809` +- A second independent render was byte-identical. + +The path is strict UTF-8 → format 4 cmap → hmtx advances → simple/composite +glyf contours → fixed-point positioned quadratic paths → retained display +resources → eight-by-eight deterministic CPU coverage → canonical PNG. + +The screenshot was inspected at original resolution. “Zagkit”, the supporting +line, and the CPU-oracle badge contain real antialiased Noto Sans outlines; +there are no placeholder bars or pixel-font substitutions. + +## Renderer correction exposed by the scene + +The initial sentence render hit `CpuRasterError.work_limit`. The old safety +estimate charged every path edge to every pixel, and the implementation also +performed that brute-force loop. The CPU oracle now builds the active edge set +for each subpixel scanline, accounts for that actual work before touching the +surface, and reuses bounded row coverage storage. The original adversarial work +limit still fails closed, while a 100-contour/100×100 scanline fixture renders +all 10,000 pixels within the same unchanged 50,000,000-work ceiling. + +## Honest limits + +This proof is nominal LTR text. It does not certify normalization, grapheme +segmentation, bidi, line breaking, fallback selection, GSUB/GPOS, variation, +CFF/CFF2, color glyphs, hinting, font synthesis, editing, IME, or accessible +text navigation. The checked-in PNG is a local evidence artifact and the +recorded font hash is required to reproduce its exact bytes. diff --git a/src/render/cpu_raster.zag b/src/render/cpu_raster.zag index 86d882a..fc0e215 100644 --- a/src/render/cpu_raster.zag +++ b/src/render/cpu_raster.zag @@ -319,6 +319,11 @@ fn cpu_path_contains(edges: ArrayList[CpuPathEdge], fill_rule: PathFillRule, return (winding != 0) as i32; } +fn cpu_path_edge_active(edge: CpuPathEdge, y: i64) i32 { + return ((edge.start.y <= y && edge.end.y > y) || + (edge.end.y <= y && edge.start.y > y)) as i32; +} + fn cpu_fill_path(surface: *CpuSurface, path: PathData, transform: Transform, clip: Rect, paint: Paint) CpuRasterResult { let built: CpuPathBuildResult = cpu_path_build(path, transform); @@ -343,25 +348,74 @@ fn cpu_fill_path(surface: *CpuSurface, path: PathData, transform: Transform, let first_y: i32 = (bounds.y / unit_scale()) as i32; let last_x: i32 = ((bounds.x + bounds.width + unit_scale() - 1) / unit_scale()) as i32; let last_y: i32 = ((bounds.y + bounds.height + unit_scale() - 1) / unit_scale()) as i32; - let pixel_count: i64 = ((last_x - first_x) as i64) * ((last_y - first_y) as i64); + let pixel_width: i32 = last_x - first_x; + let pixel_height: i32 = last_y - first_y; + let pixel_count: i64 = (pixel_width as i64) * (pixel_height as i64); let samples: i64 = (cpu_path_sample_axis() as i64) * (cpu_path_sample_axis() as i64); - if (built.edges.len > 0 && (pixel_count <= 0 || - pixel_count > cpu_path_work_limit() / samples / (built.edges.len as i64))) { + if (pixel_count <= 0) { free[CpuPathEdge](&built.edges); return cpu_result(surface.*, CpuRasterError.work_limit, 0 - 1, 0, 0); } + // Charge only edges that can cross each sample row. The previous + // all-edges-per-pixel estimate rejected ordinary glyphs and mirrored an + // avoidable brute-force loop rather than the geometry's real work. + let work: i64 = 0; + let work_y: i32 = first_y; + while (work_y < last_y) { + let work_sample_y: i32 = 0; + while (work_sample_y < cpu_path_sample_axis()) { + let point_y: i64 = (work_y as i64) * unit_scale() + + ((work_sample_y as i64) * unit_scale() + unit_scale() / 2) / + (cpu_path_sample_axis() as i64); + let active_count: i64 = 0; + let edge_index: i32 = 0; + while (edge_index < built.edges.len) { + if (cpu_path_edge_active(built.edges.data[edge_index], point_y) != 0) { + active_count = active_count + 1; + } + edge_index = edge_index + 1; + } + work = work + built.edges.len + + active_count * (pixel_width as i64) * cpu_path_sample_axis(); + if (work > cpu_path_work_limit()) { + free[CpuPathEdge](&built.edges); + return cpu_result(surface.*, CpuRasterError.work_limit, 0 - 1, 0, 0); + } + work_sample_y = work_sample_y + 1; + } + work_y = work_y + 1; + } + let touched: i64 = 0; + let active_edges: ArrayList[CpuPathEdge] = make[CpuPathEdge](built.edges.len + 1); + let row_coverage: ArrayList[i32] = make[i32](pixel_width + 1); + let coverage_index: i32 = 0; + while (coverage_index < pixel_width) { + push[i32](&row_coverage, 0); coverage_index = coverage_index + 1; + } let y: i32 = first_y; while (y < last_y) { - let x: i32 = first_x; - while (x < last_x) { - let inside: i64 = 0; - let sample_y: i32 = 0; - while (sample_y < cpu_path_sample_axis()) { - let point_y: i64 = (y as i64) * unit_scale() + - ((sample_y as i64) * unit_scale() + unit_scale() / 2) / - (cpu_path_sample_axis() as i64); + coverage_index = 0; + while (coverage_index < pixel_width) { + row_coverage.data[coverage_index] = 0; coverage_index = coverage_index + 1; + } + let sample_y: i32 = 0; + while (sample_y < cpu_path_sample_axis()) { + let point_y: i64 = (y as i64) * unit_scale() + + ((sample_y as i64) * unit_scale() + unit_scale() / 2) / + (cpu_path_sample_axis() as i64); + active_edges.len = 0; + let edge_index: i32 = 0; + while (edge_index < built.edges.len) { + let edge: CpuPathEdge = built.edges.data[edge_index]; + if (cpu_path_edge_active(edge, point_y) != 0) { + push[CpuPathEdge](&active_edges, edge); + } + edge_index = edge_index + 1; + } + let x: i32 = first_x; + while (x < last_x) { let sample_x: i32 = 0; while (sample_x < cpu_path_sample_axis()) { let point_x: i64 = (x as i64) * unit_scale() + @@ -369,13 +423,19 @@ fn cpu_fill_path(surface: *CpuSurface, path: PathData, transform: Transform, (cpu_path_sample_axis() as i64); if (point_x >= clip.x && point_x < clip.x + clip.width && point_y >= clip.y && point_y < clip.y + clip.height && - cpu_path_contains(built.edges, path.fill_rule, point_x, point_y) != 0) { - inside = inside + 1; + cpu_path_contains(active_edges, path.fill_rule, point_x, point_y) != 0) { + row_coverage.data[x - first_x] = + row_coverage.data[x - first_x] + 1; } sample_x = sample_x + 1; } - sample_y = sample_y + 1; + x = x + 1; } + sample_y = sample_y + 1; + } + let x: i32 = first_x; + while (x < last_x) { + let inside: i64 = row_coverage.data[x - first_x] as i64; if (inside > 0) { cpu_blend_pixel(surface, x, y, paint, (inside * 4096) / samples); touched = touched + 1; @@ -384,6 +444,8 @@ fn cpu_fill_path(surface: *CpuSurface, path: PathData, transform: Transform, } y = y + 1; } + free[CpuPathEdge](&active_edges); + free[i32](&row_coverage); free[CpuPathEdge](&built.edges); return cpu_result(surface.*, CpuRasterError.none, 0 - 1, 0, touched); } diff --git a/tests/cpu_raster_contract.zag b/tests/cpu_raster_contract.zag index 1da0967..34115af 100644 --- a/tests/cpu_raster_contract.zag +++ b/tests/cpu_raster_contract.zag @@ -345,6 +345,29 @@ fn path_work_limit_contract(state: *TestState) void { path_free(&path); } +fn path_scanline_work_contract(state: *TestState) void { + let path: PathData = path_make(PathFillRule.non_zero); + let row: i32 = 0; + while (row < 100) { + let top: i64 = (row as i64) * unit_scale(); + let bottom: i64 = ((row + 1) as i64) * unit_scale(); + _ = path_move_to(&path, path_point(0, top)); + _ = path_line_to(&path, path_point(100 * unit_scale(), top)); + _ = path_line_to(&path, path_point(100 * unit_scale(), bottom)); + _ = path_line_to(&path, path_point(0, bottom)); + _ = path_close(&path); + row = row + 1; + } + _ = path_seal(&path); + let list: DisplayList = path_scene(path, + paint_rgba16(65535, 65535, 65535, 65535)); + let result: CpuRasterResult = cpu_rasterize(list, 100, 100); + expect(state, result.error == CpuRasterError.none && + result.pixels_touched == 10000, + "scanline-active edge accounting renders large sparse contour sets within budget"); + cpu_raster_free(&result); display_list_free(&list); path_free(&path); +} + fn path_clip_transform_contract(state: *TestState) void { let path: PathData = square_path(PathFillRule.non_zero, 0); let payload: PathEncodeResult = path_encode(path); @@ -423,6 +446,7 @@ fn main() i32 { stroke_rect_contract(&state); image_contract(&state); path_fill_contract(&state); + path_scanline_work_contract(&state); path_work_limit_contract(&state); path_clip_transform_contract(&state); failure_contract(&state); diff --git a/tools/render-system-font-reference.sh b/tools/render-system-font-reference.sh new file mode 100755 index 0000000..ace814c --- /dev/null +++ b/tools/render-system-font-reference.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +font=${1:-} +output=${2:-$root/artifacts/evidence/linux-typography-cpu-oracle.png} +znc=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} + +if [ -z "$font" ] && command -v fc-match >/dev/null 2>&1; then + font=$(fc-match -f '%{file}\n' 'Noto Sans' | head -n 1) +fi +if [ -z "$font" ] || [ ! -f "$font" ]; then + printf 'typography reference: FAIL: no font file resolved\n' >&2 + exit 2 +fi + +mkdir -p "$(dirname -- "$output")" +binary=$(mktemp /tmp/zagkit-render-system-font.XXXXXX) +"$znc" "$root/tools/render-system-font-reference.zag" --no-zagd \ + --analyze-strict --no-foreground-cache -o "$binary" +"$binary" "$font" "$output" +printf 'typography reference: PASS (%s using %s)\n' "$output" "$font" diff --git a/tools/render-system-font-reference.zag b/tools/render-system-font-reference.zag new file mode 100644 index 0000000..1aa0447 --- /dev/null +++ b/tools/render-system-font-reference.zag @@ -0,0 +1,118 @@ +@import("../src/text/glyph_run.zag") +@import("../src/render/png_encode.zag") + +fn typography_fill(list: *DisplayList, id: i64, bounds: Rect, paint: Paint) i32 { + let op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(id)); + op.bounds = bounds; op.paint = paint; + return (display_list_push(list, op) == DisplayError.none) as i32; +} + +fn typography_round(list: *DisplayList, id: i64, bounds: Rect, + radius: i64, paint: Paint) i32 { + let op: DisplayOp = display_op(DisplayOpKind.fill_rounded_rect, node_key(id)); + op.bounds = bounds; op.parameter = radius; op.paint = paint; + return (display_list_push(list, op) == DisplayError.none) as i32; +} + +fn typography_text(list: *DisplayList, face: OpenTypeFace, id: i64, + text: []u8, size: i64, x: i64, baseline: i64, paint: Paint) i32 { + let run: NominalGlyphRun = nominal_glyph_run_ltr(face, text, size, x, baseline); + if (run.error != GlyphRunError.none) { nominal_glyph_run_free(&run); return 0; } + let glyph_index: i32 = 0; + while (glyph_index < run.glyphs.len) { + let glyph: PositionedGlyph = run.glyphs.data[glyph_index]; + let outline: GlyphOutline = opentype_glyph_outline(face, glyph.glyph_id); + if (outline.error != OpenTypeError.none) { + opentype_glyph_outline_free(&outline); nominal_glyph_run_free(&run); return 0; + } + if (outline.points.len > 0) { + let glyph_path: GlyphPathResult = glyph_outline_to_path(outline, + face.units_per_em, run.font_size, glyph.origin_x, glyph.origin_y); + opentype_glyph_outline_free(&outline); + if (glyph_path.error != GlyphPathError.none) { + glyph_path_result_free(&glyph_path); nominal_glyph_run_free(&run); return 0; + } + let encoded: PathEncodeResult = path_encode(glyph_path.path); + glyph_path_result_free(&glyph_path); + if (encoded.error != PathError.none) { + path_encode_free(&encoded); nominal_glyph_run_free(&run); return 0; + } + let glyph_resource_id: i64 = id + glyph_index; + let resource_error: RenderResourceError = display_list_add_resource(list, + render_resource_spec(glyph_resource_id, RenderResourceKind.path, + encoded.bytes.data[0..encoded.bytes.len], path_resource_format_tag())); + path_encode_free(&encoded); + if (resource_error != RenderResourceError.none) { + nominal_glyph_run_free(&run); return 0; + } + let draw: DisplayOp = display_op(DisplayOpKind.draw_path, + node_key(glyph_resource_id)); + draw.resource_id = glyph_resource_id; draw.paint = paint; + if (display_list_push(list, draw) != DisplayError.none) { + nominal_glyph_run_free(&run); return 0; + } + } else { + opentype_glyph_outline_free(&outline); + } + glyph_index = glyph_index + 1; + } + nominal_glyph_run_free(&run); + return 1; +} + +fn main() i32 { + if (_zag_argc() != 3) { + _zag_eprintln("usage: render-system-font-reference "); + return 2; + } + let font_bytes: []u8 = _zag_read_file(_zag_arg(1)); + if (font_bytes.len <= 0) { return 3; } + let face: OpenTypeFace = opentype_parse(font_bytes); + _zag_free(_zag_slice_ptr(font_bytes)); + if (face.error != OpenTypeError.none) { opentype_face_free(&face); return 4; } + let list: DisplayList = display_list_make(); + let scale: i64 = unit_scale(); + let ok: i32 = 1; + ok = ok & typography_fill(&list, 1, rect(0, 0, 960 * scale, 320 * scale), + paint_rgba16(900, 1300, 2600, 65535)); + ok = ok & typography_round(&list, 2, + rect(36 * scale, 32 * scale, 888 * scale, 256 * scale), 30 * scale, + paint_rgba16(3500, 5200, 9800, 62000)); + ok = ok & typography_round(&list, 3, + rect(72 * scale, 58 * scale, 78 * scale, 8 * scale), 4 * scale, + paint_rgba16(12000, 50000, 65535, 65535)); + ok = ok & typography_text(&list, face, 1000, "Zagkit", 84 * scale, + 72 * scale, 158 * scale, paint_rgba16(61000, 63500, 65535, 65535)); + ok = ok & typography_text(&list, face, 2000, "Fluid. precise. native.", 31 * scale, + 76 * scale, 221 * scale, paint_rgba16(34000, 40500, 52000, 65535)); + ok = ok & typography_round(&list, 4, + rect(760 * scale, 74 * scale, 118 * scale, 42 * scale), 21 * scale, + paint_rgba16(8500, 33000, 56000, 56000)); + ok = ok & typography_text(&list, face, 3000, "CPU oracle", 16 * scale, + 776 * scale, 101 * scale, paint_rgba16(44000, 56000, 65535, 65535)); + if (ok == 0 || display_list_seal(&list) != DisplayError.none) { + display_list_free(&list); opentype_face_free(&face); return 5; + } + let raster: CpuRasterResult = cpu_rasterize(list, 960, 320); + if (raster.error != CpuRasterError.none) { + let raster_error: []u8 = _zag_i64_to_str(raster.error as i64); + let raster_op: []u8 = _zag_i64_to_str(raster.error_op as i64); + _zag_eprintln("typography reference: CPU raster error follows"); + _zag_eprintln(raster_error); _zag_str_free(raster_error); + _zag_eprintln("typography reference: failing operation follows"); + _zag_eprintln(raster_op); _zag_str_free(raster_op); + cpu_raster_free(&raster); display_list_free(&list); + opentype_face_free(&face); return 6; + } + let png: PngEncodeResult = png_encode_surface(raster.surface); + if (png.error != PngEncodeError.none) { + png_encode_free(&png); cpu_raster_free(&raster); + display_list_free(&list); opentype_face_free(&face); return 7; + } + let write_error: i32 = _zag_write_file(_zag_arg(2), + png.bytes.data[0..png.bytes.len]); + png_encode_free(&png); cpu_raster_free(&raster); + display_list_free(&list); opentype_face_free(&face); + if (write_error != 0) { return 8; } + return 0; +} From 92a002bd85b3706f43dc65c3be8bc91e393e2807 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 02:19:00 -0700 Subject: [PATCH 081/103] Render owned typography in the Linux preview --- docs/evidence/linux-x11-preview-2026-08-08.md | 29 +++- examples/linux_preview.zag | 32 +++- src/showcase/linux_preview_scene.zag | 164 +++++++++++++++--- tools/run-linux-preview.sh | 11 +- 4 files changed, 199 insertions(+), 37 deletions(-) diff --git a/docs/evidence/linux-x11-preview-2026-08-08.md b/docs/evidence/linux-x11-preview-2026-08-08.md index 82bd9bd..33f5314 100644 --- a/docs/evidence/linux-x11-preview-2026-08-08.md +++ b/docs/evidence/linux-x11-preview-2026-08-08.md @@ -19,6 +19,22 @@ Status: **experimental transport proof**, not a polished component or Linux beta - The legacy title is ASCII-safe and `_NET_WM_NAME` contains the intended UTF-8 title `Zagkit — Linux Preview`. +## Typography activation — 2026-08-09 + +The native preview now requires an explicit `ZAGKIT_FONT_FILE`; the launcher +resolves Noto Sans with fontconfig when the caller did not provide one. Missing +or invalid font bytes fail before the window claims truthful typography. + +Using Noto Sans Regular SHA-256 +`89c3c497f618fdaa0b2d1e98fef93582f28c71debd2c4a8cdf41f190ced2909d`, +the X11 fallback rendered and captured real owned outlines for navigation, +titles, actions, status chips, and activity labels. Two independent native +captures were byte-identical. The current 1120×720 evidence is +`artifacts/evidence/linux-x11-typography-2026-08-09.png`, SHA-256 +`60d27685be55360d505e3b3fd9b487c46b49330764e1961ec5be7f1d82d754b9`. +Original-resolution inspection caught and removed the previous placeholder bars +under the main title before this hash was accepted. + ## Upstream Zag issue fixed The first X11 launch exposed a compiler defect in nested dynamic C calls. When @@ -38,9 +54,10 @@ X11 create/present/capture/cleanup gate. ## Honest limits -This frame is an early renderer/shell witness. It does not yet contain real -typography, icons, text shaping, controls, semantics, accessibility, IME, -clipboard, drag and drop, multi-window behavior, GPU presentation, Wayland, or -production materials and motion. Its visual quality does not satisfy the Linux -polish gate or the SwiftUI/Web UI competitive target. Those remain checked by -the project goal and agent checklist rather than being inferred from this image. +This frame is still an early renderer/shell witness. It now contains real +nominal LTR typography, but not shaping, fallback, icons, wired controls, +platform accessibility, IME, clipboard, drag and drop, multi-window behavior, +GPU presentation, Wayland, or production materials and motion. Its visual +quality is improved but does not yet satisfy the Linux polish gate or the +SwiftUI/Web UI competitive target. Those remain checked by the project goal and +agent checklist rather than being inferred from this image. diff --git a/examples/linux_preview.zag b/examples/linux_preview.zag index 87583bc..db6fab1 100644 --- a/examples/linux_preview.zag +++ b/examples/linux_preview.zag @@ -3,8 +3,8 @@ @import("../src/render/png_encode.zag") fn preview_render(host: *LinuxX11Window, width: i32, height: i32, - capture_path: []u8) i32 { - let list: DisplayList = linux_preview_scene(width, height); + capture_path: []u8, face: OpenTypeFace) i32 { + let list: DisplayList = linux_preview_scene_with_font(width, height, face); if (display_list_verify(list) != DisplayError.none) { display_list_free(&list); _zag_eprintln("zagkit: Linux preview display list verification failed"); @@ -50,6 +50,24 @@ fn main() i32 { capture_path = _zag_arg(2); } + let font_path: []u8 = _zag_getenv("ZAGKIT_FONT_FILE"); + if (font_path.len == 0) { + _zag_eprintln("zagkit: ZAGKIT_FONT_FILE is required for truthful native typography"); + return 3; + } + let font_bytes: []u8 = _zag_read_file(font_path); + if (font_bytes.len <= 0) { + _zag_eprintln("zagkit: configured font bytes are unavailable"); + return 3; + } + let face: OpenTypeFace = opentype_parse(font_bytes); + _zag_free(_zag_slice_ptr(font_bytes)); + if (face.error != OpenTypeError.none) { + _zag_eprintln("zagkit: configured font failed owned OpenType validation"); + opentype_face_free(&face); + return 3; + } + let wayland_available: i32 = (_zag_getenv("WAYLAND_DISPLAY").len > 0) as i32; let host: LinuxX11Window = linux_x11_open(1120, 720, "Zagkit — Linux Preview", wayland_available); @@ -62,14 +80,17 @@ fn main() i32 { if (host.open == 0) { _zag_eprintln("zagkit: native Linux window unavailable; capability report remains fail-closed"); linux_x11_close(&host); + opentype_face_free(&face); return 3; } - if (preview_render(&host, 1120, 720, capture_path) == 0) { + if (preview_render(&host, 1120, 720, capture_path, face) == 0) { linux_x11_close(&host); + opentype_face_free(&face); return 4; } if (capture_once != 0) { linux_x11_close(&host); + opentype_face_free(&face); return 0; } @@ -77,13 +98,14 @@ fn main() i32 { let event: LinuxX11Event = linux_x11_next_event(&host); if (event.kind == LinuxX11EventKind.close) { host.open = 0; } else if (event.kind == LinuxX11EventKind.expose) { - _ = preview_render(&host, host.width, host.height, ""); + _ = preview_render(&host, host.width, host.height, "", face); } else if (event.kind == LinuxX11EventKind.resize) { let next_width: i32 = preview_clamp_dimension(event.width, 480, 1920); let next_height: i32 = preview_clamp_dimension(event.height, 320, 1200); - _ = preview_render(&host, next_width, next_height, ""); + _ = preview_render(&host, next_width, next_height, "", face); } } linux_x11_close(&host); + opentype_face_free(&face); return 0; } diff --git a/src/showcase/linux_preview_scene.zag b/src/showcase/linux_preview_scene.zag index 9976efa..de3f263 100644 --- a/src/showcase/linux_preview_scene.zag +++ b/src/showcase/linux_preview_scene.zag @@ -1,4 +1,5 @@ @import("../render/cpu_raster.zag") +@import("../text/glyph_run.zag") fn preview_fx(value: i64) i64 { return value * unit_scale(); } @@ -10,6 +11,13 @@ fn preview_y(value: i64, height: i32) i64 { return (value * (height as i64) * unit_scale()) / 720; } +fn preview_scale(value: i64, width: i32, height: i32) i64 { + let x: i64 = preview_x(value, width); + let y: i64 = preview_y(value, height); + if (x < y) { return x; } + return y; +} + fn preview_push_rect(list: *DisplayList, id: i64, bounds: Rect, paint: Paint) i32 { let op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(id)); @@ -44,6 +52,38 @@ fn preview_add_path(list: *DisplayList, id: i64, path: *PathData, return (display_list_push(list, op) == DisplayError.none) as i32; } +fn preview_add_text(list: *DisplayList, face: OpenTypeFace, id_base: i64, + text: []u8, size: i64, x: i64, baseline: i64, paint: Paint) i32 { + let run: NominalGlyphRun = nominal_glyph_run_ltr( + face, text, size, x, baseline); + if (run.error != GlyphRunError.none) { nominal_glyph_run_free(&run); return 0; } + let glyph_index: i32 = 0; + while (glyph_index < run.glyphs.len) { + let glyph: PositionedGlyph = run.glyphs.data[glyph_index]; + let outline: GlyphOutline = opentype_glyph_outline(face, glyph.glyph_id); + if (outline.error != OpenTypeError.none) { + opentype_glyph_outline_free(&outline); nominal_glyph_run_free(&run); return 0; + } + if (outline.points.len > 0) { + let glyph_path: GlyphPathResult = glyph_outline_to_path(outline, + face.units_per_em, run.font_size, glyph.origin_x, glyph.origin_y); + opentype_glyph_outline_free(&outline); + if (glyph_path.error != GlyphPathError.none) { + glyph_path_result_free(&glyph_path); nominal_glyph_run_free(&run); return 0; + } + let resource_id: i64 = id_base + glyph_index; + if (preview_add_path(list, resource_id, &glyph_path.path, paint) == 0) { + nominal_glyph_run_free(&run); return 0; + } + } else { + opentype_glyph_outline_free(&outline); + } + glyph_index = glyph_index + 1; + } + nominal_glyph_run_free(&run); + return 1; +} + fn preview_round_rect(list: *DisplayList, id: i64, x: i64, y: i64, width: i64, height: i64, radius: i64, paint: Paint) i32 { let r: i64 = radius; @@ -75,7 +115,8 @@ fn preview_glass_panel(list: *DisplayList, id: i64, x: i64, y: i64, paint_rgba16(42000, 56000, 65535, 17000)); } -fn linux_preview_scene(width: i32, height: i32) DisplayList { +fn linux_preview_scene_internal(width: i32, height: i32, + face: OpenTypeFace, has_font: i32) DisplayList { let list: DisplayList = display_list_make(); let full: Rect = rect(0, 0, (width as i64) * unit_scale(), (height as i64) * unit_scale()); @@ -135,12 +176,14 @@ fn linux_preview_scene(width: i32, height: i32) DisplayList { preview_x(15, width), paint_rgba16(18000, 52000, 65535, 65535)); _ = preview_circle(&list, 501, preview_x(62, width), preview_y(48, height), preview_x(7, width), paint_rgba16(52000, 63000, 65535, 56000)); - _ = preview_round_rect(&list, 502, preview_x(88, width), preview_y(39, height), - preview_x(128, width), preview_y(12, height), preview_x(6, width), - paint_rgba16(52000, 58000, 65000, 60000)); - _ = preview_round_rect(&list, 503, preview_x(88, width), preview_y(58, height), - preview_x(76, width), preview_y(7, height), preview_x(4, width), - paint_rgba16(25000, 34000, 50000, 50000)); + if (has_font == 0) { + _ = preview_round_rect(&list, 502, preview_x(88, width), preview_y(39, height), + preview_x(128, width), preview_y(12, height), preview_x(6, width), + paint_rgba16(52000, 58000, 65000, 60000)); + _ = preview_round_rect(&list, 503, preview_x(88, width), preview_y(58, height), + preview_x(76, width), preview_y(7, height), preview_x(4, width), + paint_rgba16(25000, 34000, 50000, 50000)); + } _ = preview_round_rect(&list, 504, preview_x(935, width), preview_y(36, height), preview_x(132, width), preview_y(32, height), preview_x(16, width), paint_rgba16(12500, 38500, 61000, 58000)); @@ -160,10 +203,12 @@ fn linux_preview_scene(width: i32, height: i32) DisplayList { preview_x(7, width), paint_rgba16( 18000 + (nav as i64) * 1800, 35000 + (nav as i64) * 1900, 56000, 52000)); - _ = preview_round_rect(&list, 640 + nav as i64, - preview_x(94, width), ny + preview_y(4, height), - preview_x(86 + ((nav % 3) as i64) * 18, width), preview_y(10, height), - preview_x(5, width), paint_rgba16(33000, 41000, 53500, 48000)); + if (has_font == 0) { + _ = preview_round_rect(&list, 640 + nav as i64, + preview_x(94, width), ny + preview_y(4, height), + preview_x(86 + ((nav % 3) as i64) * 18, width), preview_y(10, height), + preview_x(5, width), paint_rgba16(33000, 41000, 53500, 48000)); + } nav = nav + 1; } @@ -171,12 +216,14 @@ fn linux_preview_scene(width: i32, height: i32) DisplayList { _ = preview_round_rect(&list, 700, preview_x(284, width), preview_y(132, height), preview_x(492, width), preview_y(72, height), preview_x(20, width), paint_rgba16(9000, 15500, 27500, 52000)); - _ = preview_round_rect(&list, 701, preview_x(310, width), preview_y(151, height), - preview_x(184, width), preview_y(14, height), preview_x(7, width), - paint_rgba16(52000, 57500, 64500, 60000)); - _ = preview_round_rect(&list, 702, preview_x(310, width), preview_y(175, height), - preview_x(116, width), preview_y(8, height), preview_x(4, width), - paint_rgba16(24500, 33000, 47500, 50000)); + if (has_font == 0) { + _ = preview_round_rect(&list, 701, preview_x(310, width), preview_y(151, height), + preview_x(184, width), preview_y(14, height), preview_x(7, width), + paint_rgba16(52000, 57500, 64500, 60000)); + _ = preview_round_rect(&list, 702, preview_x(310, width), preview_y(175, height), + preview_x(116, width), preview_y(8, height), preview_x(4, width), + paint_rgba16(24500, 33000, 47500, 50000)); + } _ = preview_round_rect(&list, 703, preview_x(650, width), preview_y(148, height), preview_x(98, width), preview_y(34, height), preview_x(17, width), paint_rgba16(17000, 48500, 65535, 58000)); @@ -232,14 +279,16 @@ fn linux_preview_scene(width: i32, height: i32) DisplayList { preview_x(876, width), preview_y(ay + 27, height), preview_x(11, width), paint_rgba16(15000 + (activity as i64) * 6000, 51000 - (activity as i64) * 3500, 62000, 56000)); - _ = preview_round_rect(&list, 880 + activity as i64, - preview_x(900, width), preview_y(ay + 17, height), - preview_x(126, width), preview_y(10, height), preview_x(5, width), - paint_rgba16(41000, 48500, 59000, 50000)); - _ = preview_round_rect(&list, 900 + activity as i64, - preview_x(900, width), preview_y(ay + 38, height), - preview_x(88, width), preview_y(7, height), preview_x(4, width), - paint_rgba16(22000, 30000, 44000, 43000)); + if (has_font == 0) { + _ = preview_round_rect(&list, 880 + activity as i64, + preview_x(900, width), preview_y(ay + 17, height), + preview_x(126, width), preview_y(10, height), preview_x(5, width), + paint_rgba16(41000, 48500, 59000, 50000)); + _ = preview_round_rect(&list, 900 + activity as i64, + preview_x(900, width), preview_y(ay + 38, height), + preview_x(88, width), preview_y(7, height), preview_x(4, width), + paint_rgba16(22000, 30000, 44000, 43000)); + } activity = activity + 1; } @@ -247,6 +296,71 @@ fn linux_preview_scene(width: i32, height: i32) DisplayList { rect(preview_x(24, width), preview_y(20, height), preview_x(1072, width), preview_y(64, height)), paint_rgba16(32000, 47000, 65535, 15000), unit_scale()); + if (has_font != 0) { + let title_size: i64 = preview_scale(22, width, height); + let body_size: i64 = preview_scale(15, width, height); + let quiet: Paint = paint_rgba16(35000, 43000, 56000, 60000); + let bright: Paint = paint_rgba16(57000, 61000, 65535, 65535); + _ = preview_add_text(&list, face, 10000, "Zagkit Studio", title_size, + preview_x(88, width), preview_y(61, height), bright); + _ = preview_add_text(&list, face, 10100, "Preview", body_size, + preview_x(968, width), preview_y(58, height), bright); + _ = preview_add_text(&list, face, 10200, "Overview", body_size, + preview_x(94, width), preview_y(158, height), quiet); + _ = preview_add_text(&list, face, 10300, "Components", body_size, + preview_x(94, width), preview_y(228, height), bright); + _ = preview_add_text(&list, face, 10400, "Motion", body_size, + preview_x(94, width), preview_y(298, height), quiet); + _ = preview_add_text(&list, face, 10500, "Typography", body_size, + preview_x(94, width), preview_y(368, height), quiet); + _ = preview_add_text(&list, face, 10600, "Talkback", body_size, + preview_x(94, width), preview_y(438, height), quiet); + _ = preview_add_text(&list, face, 10700, "Settings", body_size, + preview_x(94, width), preview_y(508, height), quiet); + _ = preview_add_text(&list, face, 10800, "Typography pipeline", + preview_scale(20, width, height), preview_x(310, width), + preview_y(170, height), bright); + _ = preview_add_text(&list, face, 10900, "Owned contours. Deterministic CPU.", + preview_scale(13, width, height), preview_x(310, width), + preview_y(190, height), quiet); + _ = preview_add_text(&list, face, 11000, "Inspect", + preview_scale(14, width, height), preview_x(670, width), + preview_y(171, height), bright); + _ = preview_add_text(&list, face, 11100, "State", + preview_scale(14, width, height), preview_x(318, width), + preview_y(557, height), bright); + _ = preview_add_text(&list, face, 11200, "Layout", + preview_scale(14, width, height), preview_x(437, width), + preview_y(557, height), bright); + _ = preview_add_text(&list, face, 11300, "Motion", + preview_scale(14, width, height), preview_x(559, width), + preview_y(557, height), bright); + _ = preview_add_text(&list, face, 11400, "Render", + preview_scale(14, width, height), preview_x(682, width), + preview_y(557, height), bright); + _ = preview_add_text(&list, face, 11500, "Frame pacing", + body_size, preview_x(900, width), preview_y(169, height), bright); + _ = preview_add_text(&list, face, 11600, "Semantics", + body_size, preview_x(900, width), preview_y(265, height), bright); + _ = preview_add_text(&list, face, 11700, "Motion", + body_size, preview_x(900, width), preview_y(361, height), bright); + _ = preview_add_text(&list, face, 11800, "Input", + body_size, preview_x(900, width), preview_y(457, height), bright); + _ = preview_add_text(&list, face, 11900, "Renderer", + body_size, preview_x(900, width), preview_y(553, height), bright); + } _ = display_list_seal(&list); return list; } + +fn linux_preview_scene(width: i32, height: i32) DisplayList { + let empty: OpenTypeFace = opentype_face_empty(); + let list: DisplayList = linux_preview_scene_internal(width, height, empty, 0); + opentype_face_free(&empty); + return list; +} + +fn linux_preview_scene_with_font(width: i32, height: i32, + face: OpenTypeFace) DisplayList { + return linux_preview_scene_internal(width, height, face, 1); +} diff --git a/tools/run-linux-preview.sh b/tools/run-linux-preview.sh index f4c2c1d..fcbcebc 100755 --- a/tools/run-linux-preview.sh +++ b/tools/run-linux-preview.sh @@ -14,11 +14,20 @@ if ! ldconfig -p 2>/dev/null | grep -F 'libX11.so.6' >/dev/null; then printf 'zagkit linux preview: libX11.so.6 is unavailable\n' >&2 exit 2 fi +if [ -z "${ZAGKIT_FONT_FILE:-}" ] && command -v fc-match >/dev/null 2>&1; then + ZAGKIT_FONT_FILE=$(fc-match -f '%{file}\n' 'Noto Sans' | head -n 1) + export ZAGKIT_FONT_FILE +fi +if [ -z "${ZAGKIT_FONT_FILE:-}" ] || [ ! -f "$ZAGKIT_FONT_FILE" ]; then + printf 'zagkit linux preview: no explicit system font could be resolved\n' >&2 + exit 2 +fi mkdir -p "$BUILD_DIR" printf 'zagkit: building native Linux X11 fallback preview\n' "$ZAG_BIN" "$ROOT_DIR/examples/linux_preview.zag" --dynamic \ --needed libX11.so.6 --no-zagd --analyze-strict --no-foreground-cache \ -o "$PREVIEW_BINARY" -printf 'zagkit: launching native Linux preview (%s)\n' "$PREVIEW_BINARY" +printf 'zagkit: launching native Linux preview (%s, font=%s)\n' \ + "$PREVIEW_BINARY" "$ZAGKIT_FONT_FILE" exec "$PREVIEW_BINARY" "$@" From 5c19b49c824ec6b1d35bb50704c40f8bf838dd51 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 02:24:16 -0700 Subject: [PATCH 082/103] Add bounded grapheme segmentation --- src/text/segmentation.zag | 377 ++++++++++++++++++++++++++++++++ tests/segmentation_contract.zag | 167 ++++++++++++++ 2 files changed, 544 insertions(+) create mode 100644 src/text/segmentation.zag create mode 100644 tests/segmentation_contract.zag diff --git a/src/text/segmentation.zag b/src/text/segmentation.zag new file mode 100644 index 0000000..cbc4f9a --- /dev/null +++ b/src/text/segmentation.zag @@ -0,0 +1,377 @@ +@import("unicode.zag") + +// This is a bounded foundation for Unicode extended grapheme segmentation. It +// implements the UAX #29 break-rule shapes needed by Zagkit's first text +// contracts, but the property ranges below are intentionally curated rather +// than presented as a complete, versioned Unicode Character Database. A later +// generated data package can extend the classifier without changing spans or +// ownership. + +enum GraphemeBreakClass { + other, + cr, + lf, + control, + extend, + zwj, + regional_indicator, + prepend, + spacing_mark, + hangul_l, + hangul_v, + hangul_t, + hangul_lv, + hangul_lvt, + extended_pictographic, +} + +enum GraphemeSegmentationError { + none, + input_too_large, + invalid_utf8, + too_many_scalars, + too_many_clusters, +} + +struct GraphemeSpan { + byte_start: i64, + byte_length: i64, + scalar_start: i32, + scalar_length: i32, +} + +struct GraphemeSegmentation { + spans: ArrayList[GraphemeSpan], + utf8: ArrayList[u8], + error: GraphemeSegmentationError, + utf8_error: Utf8Error, + error_offset: i64, +} + +fn grapheme_segmentation_empty() GraphemeSegmentation { + return GraphemeSegmentation{ + .spans = make[GraphemeSpan](16), + .utf8 = make[u8](32), + .error = GraphemeSegmentationError.none, + .utf8_error = Utf8Error.none, + .error_offset = 0, + }; +} + +fn grapheme_segmentation_free(result: *GraphemeSegmentation) void { + free[GraphemeSpan](&result.*.spans); + free[u8](&result.*.utf8); +} + +fn grapheme_between(value: i64, first: i64, last: i64) i32 { + return (value >= first && value <= last) as i32; +} + +fn grapheme_is_extend(value: i64) i32 { + // Common combining scripts, combining-symbol blocks, variation selectors, + // emoji modifiers, and the supplemental variation-selector plane. + if (grapheme_between(value, 768, 879) != 0 || + grapheme_between(value, 1155, 1161) != 0 || + grapheme_between(value, 1425, 1469) != 0 || value == 1471 || + grapheme_between(value, 1473, 1474) != 0 || + grapheme_between(value, 1476, 1477) != 0 || value == 1479 || + grapheme_between(value, 1552, 1562) != 0 || + grapheme_between(value, 1611, 1631) != 0 || value == 1648 || + grapheme_between(value, 1750, 1756) != 0 || + grapheme_between(value, 1759, 1764) != 0 || + grapheme_between(value, 1767, 1768) != 0 || + grapheme_between(value, 1770, 1773) != 0 || value == 1809 || + grapheme_between(value, 1840, 1866) != 0 || + grapheme_between(value, 1958, 1968) != 0 || + grapheme_between(value, 2027, 2035) != 0 || + grapheme_between(value, 2070, 2073) != 0 || + grapheme_between(value, 2075, 2083) != 0 || + grapheme_between(value, 2085, 2087) != 0 || + grapheme_between(value, 2089, 2093) != 0 || + grapheme_between(value, 2137, 2139) != 0 || + grapheme_between(value, 2304, 2306) != 0 || value == 2362 || + grapheme_between(value, 2364, 2364) != 0 || + grapheme_between(value, 2369, 2376) != 0 || value == 2381 || + grapheme_between(value, 2385, 2391) != 0 || value == 2402 || value == 2403 || + grapheme_between(value, 6832, 6911) != 0 || + grapheme_between(value, 7616, 7679) != 0 || + grapheme_between(value, 8400, 8432) != 0 || + grapheme_between(value, 65024, 65039) != 0 || + grapheme_between(value, 65056, 65071) != 0 || + grapheme_between(value, 127995, 127999) != 0 || + grapheme_between(value, 917760, 917999) != 0) { + return 1; + } + return 0; +} + +fn grapheme_is_spacing_mark(value: i64) i32 { + // Curated Indic and Southeast Asian spacing marks used by the contract and + // common editing paths. These remain separate from Extend for GB9a. + if (value == 2307 || value == 2363 || + grapheme_between(value, 2366, 2368) != 0 || + grapheme_between(value, 2377, 2380) != 0 || + grapheme_between(value, 2382, 2383) != 0 || + grapheme_between(value, 2434, 2435) != 0 || value == 2494 || + grapheme_between(value, 2495, 2500) != 0 || + grapheme_between(value, 2503, 2504) != 0 || + grapheme_between(value, 2507, 2508) != 0 || + grapheme_between(value, 2563, 2563) != 0 || + grapheme_between(value, 2622, 2624) != 0 || + grapheme_between(value, 2691, 2691) != 0 || + grapheme_between(value, 2750, 2752) != 0 || + grapheme_between(value, 2761, 2761) != 0 || + grapheme_between(value, 2818, 2819) != 0 || + grapheme_between(value, 2878, 2880) != 0 || + grapheme_between(value, 2887, 2888) != 0 || + grapheme_between(value, 2891, 2892) != 0 || + grapheme_between(value, 3006, 3010) != 0 || + grapheme_between(value, 3014, 3016) != 0 || + grapheme_between(value, 3018, 3020) != 0 || + grapheme_between(value, 3073, 3075) != 0 || + grapheme_between(value, 3137, 3140) != 0 || + grapheme_between(value, 3202, 3203) != 0 || + grapheme_between(value, 3262, 3268) != 0 || + grapheme_between(value, 3330, 3331) != 0 || + grapheme_between(value, 3390, 3396) != 0 || + grapheme_between(value, 3458, 3459) != 0 || + grapheme_between(value, 3535, 3540) != 0 || + grapheme_between(value, 3542, 3542) != 0 || + grapheme_between(value, 3544, 3551) != 0 || + grapheme_between(value, 3902, 3903) != 0 || + grapheme_between(value, 3967, 3967) != 0) { + return 1; + } + return 0; +} + +fn grapheme_is_prepend(value: i64) i32 { + if (grapheme_between(value, 1536, 1541) != 0 || value == 1757 || + value == 1807 || value == 2192 || value == 2193 || value == 2274 || + grapheme_between(value, 69821, 69821) != 0 || + grapheme_between(value, 70082, 70083) != 0 || + grapheme_between(value, 72250, 72250) != 0 || + grapheme_between(value, 72324, 72329) != 0) { + return 1; + } + return 0; +} + +fn grapheme_is_extended_pictographic(value: i64) i32 { + // Broad published emoji/symbol blocks used for GB11. Regional indicators + // are classified first so their pairing rule remains authoritative. + if (value == 169 || value == 174 || value == 8252 || value == 8265 || + grapheme_between(value, 8482, 8482) != 0 || + grapheme_between(value, 8505, 8505) != 0 || + grapheme_between(value, 8596, 8601) != 0 || + grapheme_between(value, 8617, 8618) != 0 || + grapheme_between(value, 8986, 8987) != 0 || + grapheme_between(value, 9000, 9000) != 0 || + grapheme_between(value, 9167, 9167) != 0 || + grapheme_between(value, 9193, 9210) != 0 || + grapheme_between(value, 9728, 10175) != 0 || + grapheme_between(value, 11013, 11015) != 0 || value == 11035 || + value == 11036 || value == 11088 || value == 11093 || + grapheme_between(value, 126976, 129791) != 0) { + return 1; + } + return 0; +} + +fn grapheme_break_class(value: i64) GraphemeBreakClass { + if (value == 13) { return GraphemeBreakClass.cr; } + if (value == 10) { return GraphemeBreakClass.lf; } + if ((value >= 0 && value <= 31) || grapheme_between(value, 127, 159) != 0) { + return GraphemeBreakClass.control; + } + if (value == 8205) { return GraphemeBreakClass.zwj; } + if (grapheme_between(value, 127462, 127487) != 0) { + return GraphemeBreakClass.regional_indicator; + } + if (grapheme_between(value, 4352, 4447) != 0 || + grapheme_between(value, 43360, 43388) != 0) { + return GraphemeBreakClass.hangul_l; + } + if (grapheme_between(value, 4448, 4519) != 0 || + grapheme_between(value, 55216, 55238) != 0) { + return GraphemeBreakClass.hangul_v; + } + if (grapheme_between(value, 4520, 4607) != 0 || + grapheme_between(value, 55243, 55291) != 0) { + return GraphemeBreakClass.hangul_t; + } + if (grapheme_between(value, 44032, 55203) != 0) { + if (((value - 44032) % 28) == 0) { return GraphemeBreakClass.hangul_lv; } + return GraphemeBreakClass.hangul_lvt; + } + if (grapheme_is_extend(value) != 0) { return GraphemeBreakClass.extend; } + if (grapheme_is_spacing_mark(value) != 0) { + return GraphemeBreakClass.spacing_mark; + } + if (grapheme_is_prepend(value) != 0) { return GraphemeBreakClass.prepend; } + if (grapheme_is_extended_pictographic(value) != 0) { + return GraphemeBreakClass.extended_pictographic; + } + return GraphemeBreakClass.other; +} + +fn grapheme_control_class(classification: GraphemeBreakClass) i32 { + return (classification == GraphemeBreakClass.cr || + classification == GraphemeBreakClass.lf || + classification == GraphemeBreakClass.control) as i32; +} + +fn grapheme_emoji_zwj_before(decoded: Utf8DecodeResult, + current_index: i32) i32 { + if (current_index <= 1 || + grapheme_break_class(decoded.scalars.data[current_index].value) != + GraphemeBreakClass.extended_pictographic || + grapheme_break_class(decoded.scalars.data[current_index - 1].value) != + GraphemeBreakClass.zwj) { + return 0; + } + let index: i32 = current_index - 2; + while (index >= 0 && + grapheme_break_class(decoded.scalars.data[index].value) == + GraphemeBreakClass.extend) { + index = index - 1; + } + if (index >= 0 && + grapheme_break_class(decoded.scalars.data[index].value) == + GraphemeBreakClass.extended_pictographic) { + return 1; + } + return 0; +} + +fn grapheme_regional_indicator_count(decoded: Utf8DecodeResult, + current_index: i32) i32 { + let count: i32 = 0; + let index: i32 = current_index - 1; + while (index >= 0 && + grapheme_break_class(decoded.scalars.data[index].value) == + GraphemeBreakClass.regional_indicator) { + count = count + 1; + index = index - 1; + } + return count; +} + +fn grapheme_should_break(decoded: Utf8DecodeResult, current_index: i32) i32 { + let previous: GraphemeBreakClass = grapheme_break_class( + decoded.scalars.data[current_index - 1].value); + let current: GraphemeBreakClass = grapheme_break_class( + decoded.scalars.data[current_index].value); + + // GB3, then GB4/GB5. + if (previous == GraphemeBreakClass.cr && current == GraphemeBreakClass.lf) { + return 0; + } + if (grapheme_control_class(previous) != 0 || + grapheme_control_class(current) != 0) { + return 1; + } + // GB6-GB8: conjoining Hangul syllables. + if (previous == GraphemeBreakClass.hangul_l && + (current == GraphemeBreakClass.hangul_l || + current == GraphemeBreakClass.hangul_v || + current == GraphemeBreakClass.hangul_lv || + current == GraphemeBreakClass.hangul_lvt)) { + return 0; + } + if ((previous == GraphemeBreakClass.hangul_lv || + previous == GraphemeBreakClass.hangul_v) && + (current == GraphemeBreakClass.hangul_v || + current == GraphemeBreakClass.hangul_t)) { + return 0; + } + if ((previous == GraphemeBreakClass.hangul_lvt || + previous == GraphemeBreakClass.hangul_t) && + current == GraphemeBreakClass.hangul_t) { + return 0; + } + // GB9, GB9a, and GB9b. + if (current == GraphemeBreakClass.extend || + current == GraphemeBreakClass.zwj || + current == GraphemeBreakClass.spacing_mark || + previous == GraphemeBreakClass.prepend) { + return 0; + } + // GB11 and GB12/GB13. + if (grapheme_emoji_zwj_before(decoded, current_index) != 0) { return 0; } + if (previous == GraphemeBreakClass.regional_indicator && + current == GraphemeBreakClass.regional_indicator && + (grapheme_regional_indicator_count(decoded, current_index) % 2) == 1) { + return 0; + } + return 1; +} + +fn grapheme_push_span(result: *GraphemeSegmentation, + decoded: Utf8DecodeResult, scalar_start: i32, scalar_end: i32, + byte_end: i64) i32 { + if (result.*.spans.len >= 1048576) { + result.*.error = GraphemeSegmentationError.too_many_clusters; + result.*.error_offset = byte_end; + return 0; + } + let byte_start: i64 = decoded.scalars.data[scalar_start].byte_start; + push[GraphemeSpan](&result.*.spans, GraphemeSpan{ + .byte_start = byte_start, + .byte_length = byte_end - byte_start, + .scalar_start = scalar_start, + .scalar_length = scalar_end - scalar_start, + }); + return 1; +} + +fn grapheme_segment_utf8(bytes: []u8) GraphemeSegmentation { + let result: GraphemeSegmentation = grapheme_segmentation_empty(); + if (bytes.len > 1048576) { + result.error = GraphemeSegmentationError.input_too_large; + result.utf8_error = Utf8Error.input_too_large; + result.error_offset = bytes.len as i64; + return result; + } + let decoded: Utf8DecodeResult = utf8_decode(bytes); + if (decoded.error != Utf8Error.none) { + result.error = GraphemeSegmentationError.invalid_utf8; + result.utf8_error = decoded.error; + result.error_offset = decoded.error_offset; + utf8_decode_free(&decoded); + return result; + } + if (decoded.scalars.len > 1048576) { + result.error = GraphemeSegmentationError.too_many_scalars; + result.error_offset = bytes.len as i64; + utf8_decode_free(&decoded); + return result; + } + let byte_index: i32 = 0; + while (byte_index < bytes.len) { + push[u8](&result.utf8, bytes[byte_index]); + byte_index = byte_index + 1; + } + if (decoded.scalars.len == 0) { + utf8_decode_free(&decoded); + return result; + } + let cluster_start: i32 = 0; + let scalar_index: i32 = 1; + while (scalar_index < decoded.scalars.len) { + if (grapheme_should_break(decoded, scalar_index) != 0) { + let byte_end: i64 = decoded.scalars.data[scalar_index].byte_start; + if (grapheme_push_span(&result, decoded, cluster_start, + scalar_index, byte_end) == 0) { + utf8_decode_free(&decoded); + return result; + } + cluster_start = scalar_index; + } + scalar_index = scalar_index + 1; + } + _ = grapheme_push_span(&result, decoded, cluster_start, + decoded.scalars.len, bytes.len as i64); + utf8_decode_free(&decoded); + return result; +} diff --git a/tests/segmentation_contract.zag b/tests/segmentation_contract.zag new file mode 100644 index 0000000..e6ebbfa --- /dev/null +++ b/tests/segmentation_contract.zag @@ -0,0 +1,167 @@ +@import("../src/text/segmentation.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn basic_spans(state: *TestState) void { + let empty: GraphemeSegmentation = grapheme_segment_utf8(""); + expect(state, empty.error == GraphemeSegmentationError.none && + empty.spans.len == 0 && empty.utf8.len == 0, + "empty text has no invented grapheme"); + grapheme_segmentation_free(&empty); + + let ascii: GraphemeSegmentation = grapheme_segment_utf8("AéZ"); + expect(state, ascii.error == GraphemeSegmentationError.none && + ascii.spans.len == 3 && + ascii.spans.data[0].byte_start == 0 && ascii.spans.data[0].byte_length == 1 && + ascii.spans.data[1].byte_start == 1 && ascii.spans.data[1].byte_length == 2 && + ascii.spans.data[2].byte_start == 3 && ascii.spans.data[2].byte_length == 1, + "graphemes preserve exact UTF-8 byte spans"); + expect(state, ascii.spans.data[0].scalar_start == 0 && + ascii.spans.data[1].scalar_start == 1 && + ascii.spans.data[2].scalar_length == 1, + "graphemes preserve exact scalar spans"); + grapheme_segmentation_free(&ascii); + + let source: ArrayList[u8] = make[u8](2); + push[u8](&source, 65); push[u8](&source, 66); + let owned: GraphemeSegmentation = grapheme_segment_utf8( + source.data[0..source.len]); + source.data[0] = 90; + expect(state, owned.error == GraphemeSegmentationError.none && + owned.utf8.len == 2 && owned.utf8.data[0] == 65 && owned.utf8.data[1] == 66, + "successful segmentation owns its source bytes"); + grapheme_segmentation_free(&owned); free[u8](&source); +} + +fn combining_and_controls(state: *TestState) void { + let combining: GraphemeSegmentation = grapheme_segment_utf8("e\xCC\x81x"); + expect(state, combining.error == GraphemeSegmentationError.none && + combining.spans.len == 2 && combining.spans.data[0].byte_start == 0 && + combining.spans.data[0].byte_length == 3 && + combining.spans.data[0].scalar_length == 2 && + combining.spans.data[1].byte_start == 3, + "combining marks remain attached with exact source bytes"); + grapheme_segmentation_free(&combining); + + let spacing: GraphemeSegmentation = grapheme_segment_utf8("काx"); + expect(state, spacing.error == GraphemeSegmentationError.none && + spacing.spans.len == 2 && spacing.spans.data[0].byte_length == 6 && + spacing.spans.data[0].scalar_length == 2, + "curated spacing marks follow their base"); + grapheme_segmentation_free(&spacing); + + let controls: GraphemeSegmentation = grapheme_segment_utf8("A\r\nB\nC"); + expect(state, controls.error == GraphemeSegmentationError.none && + controls.spans.len == 5 && + controls.spans.data[1].byte_start == 1 && + controls.spans.data[1].byte_length == 2 && + controls.spans.data[1].scalar_length == 2 && + controls.spans.data[3].byte_start == 4 && + controls.spans.data[3].byte_length == 1, + "CRLF remains one cluster while other controls break"); + grapheme_segmentation_free(&controls); + + let prepend: GraphemeSegmentation = grapheme_segment_utf8("\xD8\x80A"); + expect(state, prepend.error == GraphemeSegmentationError.none && + prepend.spans.len == 1 && prepend.spans.data[0].byte_length == 3 && + prepend.spans.data[0].scalar_length == 2, + "curated Prepend characters stay with the following base"); + grapheme_segmentation_free(&prepend); +} + +fn hangul_and_emoji(state: *TestState) void { + let hangul: GraphemeSegmentation = grapheme_segment_utf8("각X"); + expect(state, hangul.error == GraphemeSegmentationError.none && + hangul.spans.len == 2 && hangul.spans.data[0].byte_length == 9 && + hangul.spans.data[0].scalar_length == 3, + "Hangul L V T jamo form one grapheme"); + grapheme_segmentation_free(&hangul); + + let syllables: GraphemeSegmentation = grapheme_segment_utf8("각나"); + expect(state, syllables.error == GraphemeSegmentationError.none && + syllables.spans.len == 2 && syllables.spans.data[0].byte_length == 6 && + syllables.spans.data[0].scalar_length == 2, + "precomposed Hangul LV accepts a trailing T jamo"); + grapheme_segmentation_free(&syllables); + + let zwj: GraphemeSegmentation = grapheme_segment_utf8("👩🏽‍💻!"); + expect(state, zwj.error == GraphemeSegmentationError.none && + zwj.spans.len == 2 && zwj.spans.data[0].byte_length == 15 && + zwj.spans.data[0].scalar_length == 4 && + zwj.spans.data[1].byte_start == 15, + "emoji modifier and ZWJ pictograph form one grapheme"); + grapheme_segmentation_free(&zwj); + + let flags: GraphemeSegmentation = grapheme_segment_utf8("🇺🇸🇨🇦🇬"); + expect(state, flags.error == GraphemeSegmentationError.none && + flags.spans.len == 3 && + flags.spans.data[0].byte_length == 8 && + flags.spans.data[1].byte_start == 8 && flags.spans.data[1].byte_length == 8 && + flags.spans.data[2].byte_start == 16 && flags.spans.data[2].byte_length == 4, + "regional indicators pair from the start of each run"); + grapheme_segmentation_free(&flags); +} + +fn errors_and_determinism(state: *TestState) void { + let malformed: ArrayList[u8] = make[u8](4); + push[u8](&malformed, 65); push[u8](&malformed, 226); + push[u8](&malformed, 65); push[u8](&malformed, 128); + let invalid: GraphemeSegmentation = grapheme_segment_utf8( + malformed.data[0..malformed.len]); + expect(state, invalid.error == GraphemeSegmentationError.invalid_utf8 && + invalid.utf8_error == Utf8Error.invalid_continuation && + invalid.error_offset == 2 && invalid.spans.len == 0, + "malformed UTF-8 fails with the exact decoder error and byte"); + grapheme_segmentation_free(&invalid); free[u8](&malformed); + + let large: ArrayList[u8] = make[u8](1048577); + let index: i32 = 0; + while (index < 1048577) { push[u8](&large, 65); index = index + 1; } + let ceiling: GraphemeSegmentation = grapheme_segment_utf8( + large.data[0..large.len]); + expect(state, ceiling.error == GraphemeSegmentationError.input_too_large && + ceiling.utf8_error == Utf8Error.input_too_large && + ceiling.error_offset == 1048577 && ceiling.spans.len == 0, + "the one-megabyte source ceiling fails before decoding or copying"); + grapheme_segmentation_free(&ceiling); free[u8](&large); + + let first: GraphemeSegmentation = grapheme_segment_utf8("e\xCC\x81🇺🇸👩‍💻"); + let second: GraphemeSegmentation = grapheme_segment_utf8("e\xCC\x81🇺🇸👩‍💻"); + let equal: i32 = (first.error == second.error && + first.spans.len == second.spans.len) as i32; + let span_index: i32 = 0; + while (equal != 0 && span_index < first.spans.len) { + let a: GraphemeSpan = first.spans.data[span_index]; + let b: GraphemeSpan = second.spans.data[span_index]; + if (a.byte_start != b.byte_start || a.byte_length != b.byte_length || + a.scalar_start != b.scalar_start || a.scalar_length != b.scalar_length) { + equal = 0; + } + span_index = span_index + 1; + } + expect(state, equal != 0 && first.spans.len == 3, + "segmentation is deterministic across combining flag and ZWJ text"); + grapheme_segmentation_free(&first); grapheme_segmentation_free(&second); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + basic_spans(&state); + combining_and_controls(&state); + hangul_and_emoji(&state); + errors_and_determinism(&state); + _zag_print("Segmentation contract: pass="); _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} From cf1c56895fa2534e3b54e1a7fbfa99dbb8656ad0 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 02:27:07 -0700 Subject: [PATCH 083/103] Add canonical positioned glyph run codec --- src/text/glyph_run_codec.zag | 532 +++++++++++++++++++++++++++++ tests/glyph_run_codec_contract.zag | 242 +++++++++++++ 2 files changed, 774 insertions(+) create mode 100644 src/text/glyph_run_codec.zag create mode 100644 tests/glyph_run_codec_contract.zag diff --git a/src/text/glyph_run_codec.zag b/src/text/glyph_run_codec.zag new file mode 100644 index 0000000..157c2d5 --- /dev/null +++ b/src/text/glyph_run_codec.zag @@ -0,0 +1,532 @@ +@import("glyph_run.zag") + +// ZKGR is an experimental transport for already-positioned glyphs. It is not +// a shaping engine: a producer remains responsible for bidi resolution, +// script/language shaping, font fallback, variation selection, and color glyph +// expansion before producing this resource. + +enum GlyphRunDirection { + left_to_right, + right_to_left, + top_to_bottom, + bottom_to_top, +} + +enum GlyphRunCodecError { + none, + source_run_invalid, + bad_magic, + unsupported_version, + invalid_direction, + invalid_metadata, + invalid_language, + invalid_utf8, + invalid_count, + invalid_glyph, + invalid_cluster, + invalid_coordinate, + truncated, + trailing_data, + hash_mismatch, +} + +struct GlyphRunCodecLimitations { + experimental: i32, + already_positioned_only: i32, + embeds_font_bytes: i32, + supports_multiple_fonts: i32, + resolves_color_layers: i32, + normalizes_language_tags: i32, +} + +struct ResourceGlyph { + glyph_id: i32, + cluster_byte_offset: i32, + cluster_byte_length: i32, + origin_x: i64, + origin_y: i64, + offset_x: i64, + offset_y: i64, + advance_x: i64, + advance_y: i64, +} + +struct GlyphRunResource { + glyphs: ArrayList[ResourceGlyph], + utf8: ArrayList[u8], + language: ArrayList[u8], + direction: GlyphRunDirection, + script_tag: i64, + font_resource_id: i64, + font_size: i64, + total_advance_x: i64, + total_advance_y: i64, +} + +struct GlyphRunValidationResult { + error: GlyphRunCodecError, + error_index: i32, +} + +struct GlyphRunBuildResult { + run: GlyphRunResource, + error: GlyphRunCodecError, + error_index: i32, +} + +struct GlyphRunEncodeResult { + bytes: ArrayList[u8], + error: GlyphRunCodecError, + error_index: i32, +} + +struct GlyphRunDecodeResult { + run: GlyphRunResource, + error: GlyphRunCodecError, + error_offset: i64, +} + +fn glyph_run_resource_format_tag() i64 { return 1; } +fn glyph_run_codec_version() i64 { return 1; } +fn glyph_run_codec_header_size() i64 { return 92; } +fn glyph_run_codec_record_size() i64 { return 72; } +fn glyph_run_codec_glyph_limit() i32 { return 65536; } +fn glyph_run_codec_text_limit() i32 { return 1048576; } +fn glyph_run_codec_language_limit() i32 { return 63; } + +fn glyph_run_codec_limitations() GlyphRunCodecLimitations { + return GlyphRunCodecLimitations{ + .experimental = 1, + .already_positioned_only = 1, + .embeds_font_bytes = 0, + .supports_multiple_fonts = 0, + .resolves_color_layers = 0, + .normalizes_language_tags = 0, + }; +} + +fn glyph_run_resource_make(direction: GlyphRunDirection, script_tag: i64, + font_resource_id: i64, font_size: i64) GlyphRunResource { + return GlyphRunResource{ + .glyphs = make[ResourceGlyph](8), + .utf8 = make[u8](16), + .language = make[u8](8), + .direction = direction, + .script_tag = script_tag, + .font_resource_id = font_resource_id, + .font_size = font_size, + .total_advance_x = 0, + .total_advance_y = 0, + }; +} + +fn glyph_run_resource_free(run: *GlyphRunResource) void { + free[ResourceGlyph](&run.*.glyphs); + free[u8](&run.*.utf8); + free[u8](&run.*.language); +} + +fn glyph_run_build_free(result: *GlyphRunBuildResult) void { + glyph_run_resource_free(&result.*.run); +} + +fn glyph_run_resource_set_text(run: *GlyphRunResource, text: []u8) void { + free[u8](&run.*.utf8); + run.*.utf8 = make[u8](text.len); + let index: i32 = 0; + while (index < text.len) { + push[u8](&run.*.utf8, text[index]); + index = index + 1; + } +} + +fn glyph_run_resource_set_language(run: *GlyphRunResource, language: []u8) void { + free[u8](&run.*.language); + run.*.language = make[u8](language.len); + let index: i32 = 0; + while (index < language.len) { + push[u8](&run.*.language, language[index]); + index = index + 1; + } +} + +fn glyph_run_script_tag(tag: []u8) i64 { + if (tag.len != 4) { return 0 - 1; } + return ((tag[0] as i64) << 24) | ((tag[1] as i64) << 16) | + ((tag[2] as i64) << 8) | (tag[3] as i64); +} + +fn glyph_run_language_valid(language: ArrayList[u8]) i32 { + if (language.len == 0) { return 1; } + if (language.len > glyph_run_codec_language_limit() || + language.data[0] == 45 || language.data[language.len - 1] == 45) { return 0; } + let previous_hyphen: i32 = 0; + let index: i32 = 0; + while (index < language.len) { + let byte: i32 = language.data[index] as i32; + let alpha: i32 = ((byte >= 65 && byte <= 90) || + (byte >= 97 && byte <= 122)) as i32; + let digit: i32 = (byte >= 48 && byte <= 57) as i32; + if (alpha == 0 && digit == 0 && byte != 45) { return 0; } + if (byte == 45 && previous_hyphen != 0) { return 0; } + previous_hyphen = (byte == 45) as i32; + index = index + 1; + } + return 1; +} + +fn glyph_run_cluster_boundary(decoded: Utf8DecodeResult, offset: i32, + text_length: i32) i32 { + if (offset == 0 || offset == text_length) { return 1; } + let index: i32 = 0; + while (index < decoded.scalars.len) { + if (decoded.scalars.data[index].byte_start == offset as i64) { return 1; } + index = index + 1; + } + return 0; +} + +fn glyph_run_validation(error: GlyphRunCodecError, + index: i32) GlyphRunValidationResult { + return GlyphRunValidationResult{ .error = error, .error_index = index }; +} + +fn glyph_run_checked_add(total: i64, value: i64) i64 { + let bound: i64 = unbounded_extent(); + if (value > 0 && total > bound - value) { return bound + 1; } + if (value < 0 && total < (0 - bound) - value) { return 0 - bound - 1; } + return total + value; +} + +fn glyph_run_resource_validate(run: GlyphRunResource) GlyphRunValidationResult { + let direction_code: i64 = run.direction as i64; + if (direction_code < 0 || direction_code > 3) { + return glyph_run_validation(GlyphRunCodecError.invalid_direction, 0 - 1); + } + if (run.script_tag < 0 || run.script_tag > 4294967295 || + run.font_resource_id <= 0 || run.font_size <= 0 || + path_fixed_valid(run.font_size) == 0) { + return glyph_run_validation(GlyphRunCodecError.invalid_metadata, 0 - 1); + } + if (glyph_run_language_valid(run.language) == 0) { + return glyph_run_validation(GlyphRunCodecError.invalid_language, 0 - 1); + } + if (run.glyphs.len < 0 || run.glyphs.len > glyph_run_codec_glyph_limit() || + run.utf8.len < 0 || run.utf8.len > glyph_run_codec_text_limit()) { + return glyph_run_validation(GlyphRunCodecError.invalid_count, 0 - 1); + } + let decoded: Utf8DecodeResult = utf8_decode(run.utf8.data[0..run.utf8.len]); + if (decoded.error != Utf8Error.none) { + utf8_decode_free(&decoded); + return glyph_run_validation(GlyphRunCodecError.invalid_utf8, 0 - 1); + } + let advance_x: i64 = 0; + let advance_y: i64 = 0; + let index: i32 = 0; + while (index < run.glyphs.len) { + let glyph: ResourceGlyph = run.glyphs.data[index]; + if (glyph.glyph_id < 0) { + utf8_decode_free(&decoded); + return glyph_run_validation(GlyphRunCodecError.invalid_glyph, index); + } + if (glyph.cluster_byte_offset < 0 || glyph.cluster_byte_length < 0 || + glyph.cluster_byte_offset > run.utf8.len || + glyph.cluster_byte_length > run.utf8.len - glyph.cluster_byte_offset || + glyph_run_cluster_boundary(decoded, glyph.cluster_byte_offset, + run.utf8.len) == 0 || + glyph_run_cluster_boundary(decoded, + glyph.cluster_byte_offset + glyph.cluster_byte_length, + run.utf8.len) == 0) { + utf8_decode_free(&decoded); + return glyph_run_validation(GlyphRunCodecError.invalid_cluster, index); + } + if (path_fixed_valid(glyph.origin_x) == 0 || + path_fixed_valid(glyph.origin_y) == 0 || + path_fixed_valid(glyph.offset_x) == 0 || + path_fixed_valid(glyph.offset_y) == 0 || + path_fixed_valid(glyph.advance_x) == 0 || + path_fixed_valid(glyph.advance_y) == 0) { + utf8_decode_free(&decoded); + return glyph_run_validation(GlyphRunCodecError.invalid_coordinate, index); + } + advance_x = glyph_run_checked_add(advance_x, glyph.advance_x); + advance_y = glyph_run_checked_add(advance_y, glyph.advance_y); + if (path_fixed_valid(advance_x) == 0 || path_fixed_valid(advance_y) == 0) { + utf8_decode_free(&decoded); + return glyph_run_validation(GlyphRunCodecError.invalid_coordinate, index); + } + index = index + 1; + } + utf8_decode_free(&decoded); + if (path_fixed_valid(run.total_advance_x) == 0 || + path_fixed_valid(run.total_advance_y) == 0 || + advance_x != run.total_advance_x || advance_y != run.total_advance_y) { + return glyph_run_validation(GlyphRunCodecError.invalid_coordinate, 0 - 1); + } + return glyph_run_validation(GlyphRunCodecError.none, 0 - 1); +} + +fn glyph_run_resource_from_nominal(source: NominalGlyphRun, + font_resource_id: i64, script_tag: i64, language: []u8) GlyphRunBuildResult { + let run: GlyphRunResource = glyph_run_resource_make( + GlyphRunDirection.left_to_right, script_tag, font_resource_id, source.font_size); + if (source.error != GlyphRunError.none) { + return GlyphRunBuildResult{ + .run = run, .error = GlyphRunCodecError.source_run_invalid, + .error_index = source.error_index, + }; + } + glyph_run_resource_set_text(&run, source.utf8.data[0..source.utf8.len]); + glyph_run_resource_set_language(&run, language); + let index: i32 = 0; + while (index < source.glyphs.len) { + let glyph: PositionedGlyph = source.glyphs.data[index]; + push[ResourceGlyph](&run.glyphs, ResourceGlyph{ + .glyph_id = glyph.glyph_id, + .cluster_byte_offset = glyph.cluster_byte_offset, + .cluster_byte_length = glyph.cluster_byte_length, + .origin_x = glyph.origin_x, + .origin_y = glyph.origin_y, + .offset_x = 0, + .offset_y = 0, + .advance_x = glyph.advance_x, + .advance_y = 0, + }); + index = index + 1; + } + run.total_advance_x = source.total_advance; + let validation: GlyphRunValidationResult = glyph_run_resource_validate(run); + return GlyphRunBuildResult{ + .run = run, .error = validation.error, .error_index = validation.error_index, + }; +} + +fn glyph_run_mix(hash: i64, value: i64) i64 { + let modulus: i64 = 2147483647; + let component: i64 = value % modulus; + if (component < 0) { component = component + modulus; } + return (hash * 131 + component) % modulus; +} + +fn glyph_run_hash_bytes(hash: i64, bytes: ArrayList[u8]) i64 { + let result: i64 = glyph_run_mix(hash, bytes.len as i64); + let index: i32 = 0; + while (index < bytes.len) { + result = glyph_run_mix(result, bytes.data[index] as i64); + index = index + 1; + } + return result; +} + +fn glyph_run_resource_hash(run: GlyphRunResource) i64 { + let hash: i64 = glyph_run_mix(17, run.direction as i64); + hash = glyph_run_mix(hash, run.script_tag); + hash = glyph_run_mix(hash, run.font_resource_id); + hash = glyph_run_mix(hash, run.font_size); + hash = glyph_run_mix(hash, run.total_advance_x); + hash = glyph_run_mix(hash, run.total_advance_y); + hash = glyph_run_mix(hash, run.glyphs.len as i64); + let index: i32 = 0; + while (index < run.glyphs.len) { + let glyph: ResourceGlyph = run.glyphs.data[index]; + hash = glyph_run_mix(hash, glyph.glyph_id as i64); + hash = glyph_run_mix(hash, glyph.cluster_byte_offset as i64); + hash = glyph_run_mix(hash, glyph.cluster_byte_length as i64); + hash = glyph_run_mix(hash, glyph.origin_x); + hash = glyph_run_mix(hash, glyph.origin_y); + hash = glyph_run_mix(hash, glyph.offset_x); + hash = glyph_run_mix(hash, glyph.offset_y); + hash = glyph_run_mix(hash, glyph.advance_x); + hash = glyph_run_mix(hash, glyph.advance_y); + index = index + 1; + } + hash = glyph_run_hash_bytes(hash, run.utf8); + return glyph_run_hash_bytes(hash, run.language); +} + +fn glyph_run_write_i64(bytes: *ArrayList[u8], value: i64) void { + let index: i32 = 0; + while (index < 8) { + push[u8](bytes, ((value >> (index * 8)) & 255) as u8); + index = index + 1; + } +} + +fn glyph_run_read_i64(bytes: ArrayList[u8], offset: i64) i64 { + let value: i64 = 0; + let index: i32 = 0; + while (index < 8) { + value = value | ((bytes.data[(offset as i32) + index] as i64) << (index * 8)); + index = index + 1; + } + return value; +} + +fn glyph_run_encode_fail(error: GlyphRunCodecError, + error_index: i32) GlyphRunEncodeResult { + return GlyphRunEncodeResult{ + .bytes = make[u8](1), .error = error, .error_index = error_index, + }; +} + +fn glyph_run_resource_encode(run: GlyphRunResource) GlyphRunEncodeResult { + let validation: GlyphRunValidationResult = glyph_run_resource_validate(run); + if (validation.error != GlyphRunCodecError.none) { + return glyph_run_encode_fail(validation.error, validation.error_index); + } + let encoded_size: i64 = glyph_run_codec_header_size() + + (run.glyphs.len as i64) * glyph_run_codec_record_size() + + run.utf8.len as i64 + run.language.len as i64; + let bytes: ArrayList[u8] = make[u8](encoded_size as i32); + push[u8](&bytes, 90); push[u8](&bytes, 75); + push[u8](&bytes, 71); push[u8](&bytes, 82); + glyph_run_write_i64(&bytes, glyph_run_codec_version()); + glyph_run_write_i64(&bytes, run.direction as i64); + glyph_run_write_i64(&bytes, run.script_tag); + glyph_run_write_i64(&bytes, run.font_resource_id); + glyph_run_write_i64(&bytes, run.font_size); + glyph_run_write_i64(&bytes, run.total_advance_x); + glyph_run_write_i64(&bytes, run.total_advance_y); + glyph_run_write_i64(&bytes, run.glyphs.len as i64); + glyph_run_write_i64(&bytes, run.utf8.len as i64); + glyph_run_write_i64(&bytes, run.language.len as i64); + glyph_run_write_i64(&bytes, glyph_run_resource_hash(run)); + let index: i32 = 0; + while (index < run.glyphs.len) { + let glyph: ResourceGlyph = run.glyphs.data[index]; + glyph_run_write_i64(&bytes, glyph.glyph_id as i64); + glyph_run_write_i64(&bytes, glyph.cluster_byte_offset as i64); + glyph_run_write_i64(&bytes, glyph.cluster_byte_length as i64); + glyph_run_write_i64(&bytes, glyph.origin_x); + glyph_run_write_i64(&bytes, glyph.origin_y); + glyph_run_write_i64(&bytes, glyph.offset_x); + glyph_run_write_i64(&bytes, glyph.offset_y); + glyph_run_write_i64(&bytes, glyph.advance_x); + glyph_run_write_i64(&bytes, glyph.advance_y); + index = index + 1; + } + index = 0; + while (index < run.utf8.len) { + push[u8](&bytes, run.utf8.data[index]); index = index + 1; + } + index = 0; + while (index < run.language.len) { + push[u8](&bytes, run.language.data[index]); index = index + 1; + } + return GlyphRunEncodeResult{ + .bytes = bytes, .error = GlyphRunCodecError.none, .error_index = 0 - 1, + }; +} + +fn glyph_run_encode_free(result: *GlyphRunEncodeResult) void { + free[u8](&result.*.bytes); +} + +fn glyph_run_decode_fail(run: GlyphRunResource, error: GlyphRunCodecError, + offset: i64) GlyphRunDecodeResult { + return GlyphRunDecodeResult{ .run = run, .error = error, .error_offset = offset }; +} + +fn glyph_run_resource_decode(bytes: ArrayList[u8]) GlyphRunDecodeResult { + let run: GlyphRunResource = glyph_run_resource_make( + GlyphRunDirection.left_to_right, 0, 1, 1); + if ((bytes.len as i64) < glyph_run_codec_header_size()) { + return glyph_run_decode_fail(run, GlyphRunCodecError.truncated, bytes.len as i64); + } + if (bytes.data[0] != 90 || bytes.data[1] != 75 || + bytes.data[2] != 71 || bytes.data[3] != 82) { + return glyph_run_decode_fail(run, GlyphRunCodecError.bad_magic, 0); + } + if (glyph_run_read_i64(bytes, 4) != glyph_run_codec_version()) { + return glyph_run_decode_fail(run, GlyphRunCodecError.unsupported_version, 4); + } + let direction_code: i64 = glyph_run_read_i64(bytes, 12); + if (direction_code < 0 || direction_code > 3) { + return glyph_run_decode_fail(run, GlyphRunCodecError.invalid_direction, 12); + } + let glyph_count: i64 = glyph_run_read_i64(bytes, 60); + let text_length: i64 = glyph_run_read_i64(bytes, 68); + let language_length: i64 = glyph_run_read_i64(bytes, 76); + if (glyph_count < 0 || glyph_count > glyph_run_codec_glyph_limit() as i64 || + text_length < 0 || text_length > glyph_run_codec_text_limit() as i64 || + language_length < 0 || + language_length > glyph_run_codec_language_limit() as i64) { + return glyph_run_decode_fail(run, GlyphRunCodecError.invalid_count, 60); + } + let expected_size: i64 = glyph_run_codec_header_size() + + glyph_count * glyph_run_codec_record_size() + text_length + language_length; + if ((bytes.len as i64) < expected_size) { + return glyph_run_decode_fail(run, GlyphRunCodecError.truncated, bytes.len as i64); + } + if ((bytes.len as i64) > expected_size) { + return glyph_run_decode_fail(run, GlyphRunCodecError.trailing_data, expected_size); + } + run.direction = direction_code as GlyphRunDirection; + run.script_tag = glyph_run_read_i64(bytes, 20); + run.font_resource_id = glyph_run_read_i64(bytes, 28); + run.font_size = glyph_run_read_i64(bytes, 36); + run.total_advance_x = glyph_run_read_i64(bytes, 44); + run.total_advance_y = glyph_run_read_i64(bytes, 52); + let stored_hash: i64 = glyph_run_read_i64(bytes, 84); + let offset: i64 = glyph_run_codec_header_size(); + let index: i64 = 0; + while (index < glyph_count) { + let glyph_id: i64 = glyph_run_read_i64(bytes, offset); + let cluster_offset: i64 = glyph_run_read_i64(bytes, offset + 8); + let cluster_length: i64 = glyph_run_read_i64(bytes, offset + 16); + if (glyph_id < 0 || glyph_id > 2147483647) { + return glyph_run_decode_fail(run, GlyphRunCodecError.invalid_glyph, offset); + } + if (cluster_offset < 0 || cluster_offset > 2147483647 || + cluster_length < 0 || cluster_length > 2147483647) { + return glyph_run_decode_fail(run, GlyphRunCodecError.invalid_cluster, offset + 8); + } + push[ResourceGlyph](&run.glyphs, ResourceGlyph{ + .glyph_id = glyph_id as i32, + .cluster_byte_offset = cluster_offset as i32, + .cluster_byte_length = cluster_length as i32, + .origin_x = glyph_run_read_i64(bytes, offset + 24), + .origin_y = glyph_run_read_i64(bytes, offset + 32), + .offset_x = glyph_run_read_i64(bytes, offset + 40), + .offset_y = glyph_run_read_i64(bytes, offset + 48), + .advance_x = glyph_run_read_i64(bytes, offset + 56), + .advance_y = glyph_run_read_i64(bytes, offset + 64), + }); + offset = offset + glyph_run_codec_record_size(); + index = index + 1; + } + index = 0; + while (index < text_length) { + push[u8](&run.utf8, bytes.data[(offset + index) as i32]); index = index + 1; + } + offset = offset + text_length; + index = 0; + while (index < language_length) { + push[u8](&run.language, bytes.data[(offset + index) as i32]); index = index + 1; + } + let validation: GlyphRunValidationResult = glyph_run_resource_validate(run); + if (validation.error != GlyphRunCodecError.none) { + let validation_offset: i64 = 20; + if (validation.error_index >= 0) { + validation_offset = glyph_run_codec_header_size() + + (validation.error_index as i64) * glyph_run_codec_record_size(); + } else if (validation.error == GlyphRunCodecError.invalid_utf8) { + validation_offset = glyph_run_codec_header_size() + + glyph_count * glyph_run_codec_record_size(); + } else if (validation.error == GlyphRunCodecError.invalid_language) { + validation_offset = glyph_run_codec_header_size() + + glyph_count * glyph_run_codec_record_size() + text_length; + } + return glyph_run_decode_fail(run, validation.error, validation_offset); + } + if (stored_hash != glyph_run_resource_hash(run)) { + return glyph_run_decode_fail(run, GlyphRunCodecError.hash_mismatch, 84); + } + return GlyphRunDecodeResult{ + .run = run, .error = GlyphRunCodecError.none, .error_offset = 0 - 1, + }; +} + +fn glyph_run_decode_free(result: *GlyphRunDecodeResult) void { + glyph_run_resource_free(&result.*.run); +} diff --git a/tests/glyph_run_codec_contract.zag b/tests/glyph_run_codec_contract.zag new file mode 100644 index 0000000..5569557 --- /dev/null +++ b/tests/glyph_run_codec_contract.zag @@ -0,0 +1,242 @@ +@import("../src/text/glyph_run_codec.zag") +@import("support/opentype_fixture.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn codec_bytes_equal(a: ArrayList[u8], b: ArrayList[u8]) i32 { + if (a.len != b.len) { return 0; } + let index: i32 = 0; + while (index < a.len) { + if (a.data[index] != b.data[index]) { return 0; } + index = index + 1; + } + return 1; +} + +fn codec_set_i64(bytes: *ArrayList[u8], offset: i32, value: i64) void { + let index: i32 = 0; + while (index < 8) { + bytes.*.data[offset + index] = ((value >> (index * 8)) & 255) as u8; + index = index + 1; + } +} + +fn shaped_fixture() GlyphRunResource { + let run: GlyphRunResource = glyph_run_resource_make( + GlyphRunDirection.right_to_left, glyph_run_script_tag("Arab"), + 7001, 18 * unit_scale()); + glyph_run_resource_set_text(&run, "\xD9\x84\xD8\xA7"); + glyph_run_resource_set_language(&run, "ar-EG"); + push[ResourceGlyph](&run.glyphs, ResourceGlyph{ + .glyph_id = 901, .cluster_byte_offset = 0, .cluster_byte_length = 4, + .origin_x = 30 * unit_scale(), .origin_y = 40 * unit_scale(), + .offset_x = unit_scale() / 8, .offset_y = 0 - unit_scale() / 16, + .advance_x = 0 - 6 * unit_scale(), .advance_y = 0, + }); + push[ResourceGlyph](&run.glyphs, ResourceGlyph{ + .glyph_id = 902, .cluster_byte_offset = 0, .cluster_byte_length = 4, + .origin_x = 24 * unit_scale(), .origin_y = 40 * unit_scale(), + .offset_x = 0 - unit_scale() / 10, .offset_y = unit_scale() / 12, + .advance_x = 0 - 5 * unit_scale(), .advance_y = unit_scale() / 32, + }); + run.total_advance_x = 0 - 11 * unit_scale(); + run.total_advance_y = unit_scale() / 32; + return run; +} + +fn limitation_contract(state: *TestState) void { + let limits: GlyphRunCodecLimitations = glyph_run_codec_limitations(); + expect(state, limits.experimental == 1 && limits.already_positioned_only == 1 && + limits.embeds_font_bytes == 0 && limits.supports_multiple_fonts == 0 && + limits.resolves_color_layers == 0 && limits.normalizes_language_tags == 0, + "experimental codec states its shaping font fallback color and language limitations"); +} + +fn round_trip_contract(state: *TestState) void { + let source: GlyphRunResource = shaped_fixture(); + let encoded: GlyphRunEncodeResult = glyph_run_resource_encode(source); + expect(state, encoded.error == GlyphRunCodecError.none && encoded.bytes.len == + glyph_run_codec_header_size() as i32 + 2 * glyph_run_codec_record_size() as i32 + + source.utf8.len + source.language.len, + "shaped positions encode to the exact bounded canonical size"); + expect(state, encoded.bytes.data[0] == 90 && encoded.bytes.data[1] == 75 && + encoded.bytes.data[2] == 71 && encoded.bytes.data[3] == 82 && + glyph_run_read_i64(encoded.bytes, 4) == 1, + "resource carries ZKGR magic and an explicit version"); + let decoded: GlyphRunDecodeResult = glyph_run_resource_decode(encoded.bytes); + expect(state, decoded.error == GlyphRunCodecError.none && decoded.run.glyphs.len == 2 && + decoded.run.direction == GlyphRunDirection.right_to_left && + decoded.run.script_tag == glyph_run_script_tag("Arab") && + decoded.run.font_resource_id == 7001 && + decoded.run.font_size == 18 * unit_scale(), + "decode preserves font direction script and scale metadata"); + expect(state, decoded.run.glyphs.data[0].glyph_id == 901 && + decoded.run.glyphs.data[0].cluster_byte_offset == 0 && + decoded.run.glyphs.data[0].cluster_byte_length == 4 && + decoded.run.glyphs.data[1].offset_x == 0 - unit_scale() / 10 && + decoded.run.glyphs.data[1].offset_y == unit_scale() / 12 && + decoded.run.glyphs.data[1].advance_y == unit_scale() / 32, + "decode preserves shaped IDs clusters origins offsets and two-axis advances"); + expect(state, decoded.run.utf8.len == 4 && decoded.run.language.len == 5 && + decoded.run.utf8.data[0] == 217 && decoded.run.language.data[2] == 45 && + decoded.run.total_advance_x == 0 - 11 * unit_scale() && + decoded.run.total_advance_y == unit_scale() / 32, + "decode owns exact UTF-8 language bytes and total pen movement"); + let encoded_again: GlyphRunEncodeResult = glyph_run_resource_encode(decoded.run); + expect(state, encoded_again.error == GlyphRunCodecError.none && + codec_bytes_equal(encoded.bytes, encoded_again.bytes) != 0, + "decode and re-encode are byte identical"); + let saved: u8 = encoded.bytes.data[encoded.bytes.len - 1]; + encoded.bytes.data[encoded.bytes.len - 1] = saved ^ 1; + expect(state, decoded.run.language.data[decoded.run.language.len - 1] == saved, + "decoded text metadata owns storage independent of transport bytes"); + encoded.bytes.data[encoded.bytes.len - 1] = saved; + glyph_run_encode_free(&encoded_again); + glyph_run_decode_free(&decoded); + glyph_run_encode_free(&encoded); + glyph_run_resource_free(&source); +} + +fn nominal_bridge_contract(state: *TestState) void { + let bytes: ArrayList[u8] = fixture_font(); + let face: OpenTypeFace = opentype_parse(bytes.data[0..bytes.len]); + let nominal: NominalGlyphRun = nominal_glyph_run_ltr( + face, "AAA", 10 * unit_scale(), unit_scale(), 8 * unit_scale()); + let built: GlyphRunBuildResult = glyph_run_resource_from_nominal( + nominal, 88, glyph_run_script_tag("Latn"), "en"); + expect(state, built.error == GlyphRunCodecError.none && built.run.glyphs.len == 3 && + built.run.glyphs.data[1].glyph_id == nominal.glyphs.data[1].glyph_id && + built.run.glyphs.data[2].cluster_byte_offset == 2 && + built.run.total_advance_x == nominal.total_advance, + "nominal positioned runs copy into the general owned resource without claiming shaping"); + let encoded: GlyphRunEncodeResult = glyph_run_resource_encode(built.run); + expect(state, encoded.error == GlyphRunCodecError.none, + "nominal bridge output satisfies the same canonical resource contract"); + glyph_run_encode_free(&encoded); + glyph_run_build_free(&built); + nominal_glyph_run_free(&nominal); + opentype_face_free(&face); + free[u8](&bytes); +} + +fn rejection_contract(state: *TestState) void { + let invalid_cluster: GlyphRunResource = shaped_fixture(); + invalid_cluster.glyphs.data[0].cluster_byte_length = 1; + let cluster_result: GlyphRunEncodeResult = glyph_run_resource_encode(invalid_cluster); + expect(state, cluster_result.error == GlyphRunCodecError.invalid_cluster && + cluster_result.error_index == 0 && cluster_result.bytes.len == 0, + "encode rejects clusters that split a UTF-8 scalar"); + glyph_run_encode_free(&cluster_result); + glyph_run_resource_free(&invalid_cluster); + + let bad_total: GlyphRunResource = shaped_fixture(); + bad_total.total_advance_x = bad_total.total_advance_x + 1; + let total_result: GlyphRunEncodeResult = glyph_run_resource_encode(bad_total); + expect(state, total_result.error == GlyphRunCodecError.invalid_coordinate, + "encode rejects total pen movement inconsistent with glyph advances"); + glyph_run_encode_free(&total_result); + glyph_run_resource_free(&bad_total); + + let bad_language: GlyphRunResource = shaped_fixture(); + glyph_run_resource_set_language(&bad_language, "ar__EG"); + let language_result: GlyphRunEncodeResult = glyph_run_resource_encode(bad_language); + expect(state, language_result.error == GlyphRunCodecError.invalid_language, + "encode rejects non-canonical language metadata bytes"); + glyph_run_encode_free(&language_result); + glyph_run_resource_free(&bad_language); + + let bad_utf8: GlyphRunResource = glyph_run_resource_make( + GlyphRunDirection.left_to_right, glyph_run_script_tag("Latn"), 5, unit_scale()); + glyph_run_resource_set_text(&bad_utf8, "\xC0\xAF"); + glyph_run_resource_set_language(&bad_utf8, "en"); + let utf8_result: GlyphRunEncodeResult = glyph_run_resource_encode(bad_utf8); + expect(state, utf8_result.error == GlyphRunCodecError.invalid_utf8, + "encode rejects malformed UTF-8 instead of serializing replacement text"); + glyph_run_encode_free(&utf8_result); + glyph_run_resource_free(&bad_utf8); +} + +fn malformed_transport_contract(state: *TestState) void { + let source: GlyphRunResource = shaped_fixture(); + let encoded: GlyphRunEncodeResult = glyph_run_resource_encode(source); + let stable: i32 = 1; + let index: i32 = 0; + while (index < encoded.bytes.len) { + let saved: u8 = encoded.bytes.data[index]; + encoded.bytes.data[index] = saved ^ 1; + let mutation: GlyphRunDecodeResult = glyph_run_resource_decode(encoded.bytes); + if (mutation.error == GlyphRunCodecError.none) { stable = 0; } + glyph_run_decode_free(&mutation); + encoded.bytes.data[index] = saved; + index = index + 1; + } + expect(state, stable != 0, + "every single-byte transport mutation fails canonical decoding or identity"); + + let truncation_safe: i32 = 1; + let cut: i32 = 0; + while (cut < encoded.bytes.len) { + let prefix: ArrayList[u8] = make[u8](cut); + index = 0; + while (index < cut) { + push[u8](&prefix, encoded.bytes.data[index]); index = index + 1; + } + let truncated: GlyphRunDecodeResult = glyph_run_resource_decode(prefix); + if (truncated.error == GlyphRunCodecError.none) { truncation_safe = 0; } + glyph_run_decode_free(&truncated); + free[u8](&prefix); + cut = cut + 1; + } + expect(state, truncation_safe != 0, + "every strict prefix is rejected without reading beyond observed bytes"); + + push[u8](&encoded.bytes, 0); + let trailing: GlyphRunDecodeResult = glyph_run_resource_decode(encoded.bytes); + expect(state, trailing.error == GlyphRunCodecError.trailing_data, + "trailing bytes fail canonical resource decoding"); + glyph_run_decode_free(&trailing); + encoded.bytes.len = encoded.bytes.len - 1; + + codec_set_i64(&encoded.bytes, 4, 2); + let version: GlyphRunDecodeResult = glyph_run_resource_decode(encoded.bytes); + expect(state, version.error == GlyphRunCodecError.unsupported_version && + version.error_offset == 4, + "unsupported codec versions fail explicitly"); + glyph_run_decode_free(&version); + codec_set_i64(&encoded.bytes, 4, 1); + + codec_set_i64(&encoded.bytes, 60, + glyph_run_codec_glyph_limit() as i64 + 1); + let count: GlyphRunDecodeResult = glyph_run_resource_decode(encoded.bytes); + expect(state, count.error == GlyphRunCodecError.invalid_count && + count.error_offset == 60, + "declared glyph counts cannot cross the hard allocation bound"); + glyph_run_decode_free(&count); + + glyph_run_encode_free(&encoded); + glyph_run_resource_free(&source); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + limitation_contract(&state); + round_trip_contract(&state); + nominal_bridge_contract(&state); + rejection_contract(&state); + malformed_transport_contract(&state); + _zag_print("Glyph run codec contract: pass="); + _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} From bbadd322c948fdeffc5df1eb32315f2cfbf15936 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 03:17:50 -0700 Subject: [PATCH 084/103] Make the Linux showcase prove system rules --- GOAL.md | 3 +- docs/design/showcase-conformance.md | 73 +++++ .../linux-showcase-system-2026-08-09.md | 56 ++++ src/design/tokens.zag | 232 ++++++++++++++ src/showcase/linux_preview_scene.zag | 289 ++++++++++++------ src/showcase/linux_preview_semantics.zag | 208 +++++++++++++ tests/design_tokens_contract.zag | 74 +++++ tests/linux_preview_semantics_contract.zag | 82 +++++ tools/emit-agent-checklist.sh | 5 +- tools/test-headless.sh | 10 +- 10 files changed, 939 insertions(+), 93 deletions(-) create mode 100644 docs/design/showcase-conformance.md create mode 100644 docs/evidence/linux-showcase-system-2026-08-09.md create mode 100644 src/design/tokens.zag create mode 100644 src/showcase/linux_preview_semantics.zag create mode 100644 tests/design_tokens_contract.zag create mode 100644 tests/linux_preview_semantics_contract.zag diff --git a/GOAL.md b/GOAL.md index 51e313a..2fde43f 100644 --- a/GOAL.md +++ b/GOAL.md @@ -42,7 +42,7 @@ more precision is needed and preserve its original ID as the parent. - [ ] `G1-CONCURRENCY` Complete main-loop and worker primitives with a documented memory model. — Exit: wakeup, cancellation, affinity, race, shutdown, and platform suites pass - [ ] `G1-PACKAGES` Complete deterministic package resolution. — Exit: locks, checksums, offline, path, conflict, cache, and reproducibility suites pass - [ ] `G1-RELOAD` Complete incremental compilation and safe reload hooks. — Exit: invalidation, state preservation, rollback, file-race, and crash-recovery suites pass -- [ ] `G1-SOURCE-FIRST` Keep reusable failures out of downstream workarounds and fallback languages. — Exit: every downstream Zag defect links to an upstream Zag regression and exact fixed revision +- [ ] `G1-SOURCE-FIRST` Treat every reusable compiler, runtime, ABI, package, concurrency, tooling, or language-ergonomics problem exposed by Zagkit or PrismStudio as an upstream Zag side quest; do not preserve consumer workarounds merely because another language normally needs them. Work only in canonical `/home/micah/Desktop/Sylorlabs/zag`, add native regressions there, and resume the consumer at the exact fixed revision. — Exit: every downstream Zag defect or avoidable workaround links to an upstream Zag regression and exact fixed revision ## 2. Declarative and headless core @@ -99,6 +99,7 @@ more precision is needed and preserve its original ID as the parent. - [ ] `G5-LINUX-GPU` Implement one explicit public Linux GPU transport. — Exit: opt-in physical-device execution, loss recovery, CPU comparison, and cleanup pass - [ ] `G5-LINUX-POLISH` Make Linux the first no-rough-edges reference experience. — Exit: no known severity-one or severity-two visual, input, text, accessibility, recovery, or packaging defects remain - [ ] `G5-LINUX-FIDELITY` Eliminate density-dependent and pixelated UI output. — Exit: screenshot comparisons pass at 1.0, 1.25, 1.5, 2.0, and 3.0 scales with crisp type, SVG, PNG, curves, glass, and shadows +- [ ] `G5-SHOWCASE-CONFORMANCE` Make the Linux showcase prove a coherent reusable system rather than a one-off dashboard. — Exit: [showcase conformance](docs/design/showcase-conformance.md) passes token provenance, semantic icon/color mapping, three elevation tiers, full canonical interaction states, type ramp, unambiguous navigation/status roles, real segmented-control behavior, and accessible chart anatomy with native screenshots and semantic evidence - [ ] `G5-LINUX-PACKAGE` Package, install, launch, update, and uninstall Linux artifacts. — Exit: Ubuntu LTS and Fedora x86-64 and ARM64 release matrices pass where target support is declared ## 6. Complete PrismStudio overhaul diff --git a/docs/design/showcase-conformance.md b/docs/design/showcase-conformance.md new file mode 100644 index 0000000..f596121 --- /dev/null +++ b/docs/design/showcase-conformance.md @@ -0,0 +1,73 @@ +# Design-system showcase conformance + +The Zagkit showcase is a conformance surface, not a decorative dashboard. A +capture may demonstrate renderer progress while this contract is incomplete, +but it must be labeled experimental and cannot promote the visual system. + +## Required proof + +### Token provenance + +- Every visible color, type style, spacing value, radius, elevation, material, + and motion value resolves through a named semantic token. +- The inspector reports the token ID, resolved value, environment inputs, and + fallback used by every rendered node. +- Brand, focus, selection, status, category, warning, error, and data-series + colors have separate documented meanings. Similar-looking literals are not + accepted as token proof. +- Component-local exceptions are named, reviewable, and never silently become + a second token scale. + +### Semantic symbols and color + +- Icons and status marks use real assets and a documented semantic mapping. +- Color is never the only carrier of category, status, selection, error, or + focus. Text, shape, icon, semantics, or position carries the same truth. +- Decorative marks are hidden from accessibility. Meaningful marks expose a + role, name, value, and state. + +### Hierarchy and range + +- The same canonical card is rendered at base, panel, and raised/overlay + elevation tiers with visibly distinct fill, edge, depth, and contrast rules. +- Primary navigation, contextual inspection, status, content, and commands use + distinct component roles. A status rail may not masquerade as a second + navigation system. +- Typography demonstrates display, title, heading, body, label, caption, and + code roles with tested line-height, truncation, locale, and large-text rules. + +### Interaction states + +- Canonical Button, IconButton, navigation item, segmented control, field, + list row, menu item, and card action show default, hover, keyboard focus, + pressed, selected, disabled, loading, and error states. +- State differences remain clear in light, dark, high contrast, reduced + transparency, reduced motion, grayscale, and color-vision simulations. +- Hover is supplementary. Keyboard, touch, pen, gamepad, accessibility action, + and Zagkit Talkback reach the same action and state truth. + +### Data visualization + +- Any chart presented as a toolkit component includes named axes, units, tick + labels, a baseline or domain reference, series identity, hover/focus detail, + empty/loading/error states, and a semantic table equivalent. +- A decorative grid with dots is not called a chart component and cannot count + as component coverage. + +### Segmented controls and navigation + +- A segmented control has one declared selection model, visible selected and + focused states, arrow-key behavior, disabled behavior, and tab semantics. +- Navigation exposes one canonical active location. Contextual inspectors and + system status are labeled and styled as their actual roles. + +## Evidence matrix + +For each required component and state, record the semantic token trace, +semantics/Talkback snapshot, keyboard path, CPU golden, and native screenshot at +1.0x and 2.0x. The full visual-direction matrix adds all other scales, themes, +locales, directions, contrast, text, motion, and transparency variants. + +Screenshot review is necessary but insufficient. Promotion also requires +native input, focus, assistive-technology, deterministic rendering, idle, +frame-time, resize, recovery, and cleanup evidence. diff --git a/docs/evidence/linux-showcase-system-2026-08-09.md b/docs/evidence/linux-showcase-system-2026-08-09.md new file mode 100644 index 0000000..a07a4fc --- /dev/null +++ b/docs/evidence/linux-showcase-system-2026-08-09.md @@ -0,0 +1,56 @@ +# Linux showcase system checkpoint — 2026-08-09 + +This checkpoint demonstrates renderer and design-system contract progress. It +does **not** promote the preview, select RFC 0007, or satisfy +`G5-SHOWCASE-CONFORMANCE`. + +## Native capture + +- Generated by the current Zag compiler and the native X11 fallback with + `/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf`. +- Capture: `artifacts/evidence/linux-showcase-system-2026-08-09.png` +- Dimensions: 1120×720 RGBA. +- SHA-256: + `605d9ab5c4e732995e275d08f4276ad00e4901f03ec13fdb592db35d4c4dd532`. + +The accepted capture was opened and inspected after generation. It shows: + +- one named semantic palette instead of per-item color formulas; +- distinct panel, raised, inset, interactive, selected, and disabled surfaces; +- one primary navigation rail and a separately labelled system-status list; +- documented, non-decorative status/category colors with truthful values; +- a selected and disabled segmented-control state; +- a frame-time chart with axes, units, a 16.7 ms deadline, three named series, + and an equivalent semantic table; +- real Noto Sans outlines rendered through the Zag-owned OpenType/path/CPU + pipeline. + +## Executable evidence + +```text +Design tokens: pass=8 fail=0 +Linux preview semantics: pass=9 fail=0 +headless test: PASS +``` + +The semantic contract includes stable Talkback IDs, chart table metadata, +selected/disabled segment truth, resize-stable IDs, and scale-aware pointer hit +geometry. The complete headless gate also ran the Unicode segmentation, +positioned glyph-run codec, layout, semantics, Talkback, resources, PNG, +display-list, CPU raster, input, replay, and motion suites. + +## Honest remaining gaps + +- RFC 0007 visual direction is still proposed, so these are experimental + showcase tokens rather than accepted production tokens. +- Token values are inspectable in code but the live token-provenance inspector + is not implemented. +- Hover, actual keyboard focus, pressed, loading, error, and reduced-effects + variants are not all visible or host-driven yet. +- The CPU renderer still lacks the complete material/effect and text stack + required for final glass, shadow, lighting, shaping, fallback, and editing. +- AT-SPI, IME, keyboard routing, clipboard, drag/drop, Wayland, GPU transport, + packaging, and multi-monitor recovery remain unavailable. +- Native pointer selection exposed Zag's missing explicit C-record layout. That + reusable issue is being fixed and conformance-tested in canonical Zag; no + offset workaround is accepted as final Zagkit input code. diff --git a/src/design/tokens.zag b/src/design/tokens.zag new file mode 100644 index 0000000..9412dcb --- /dev/null +++ b/src/design/tokens.zag @@ -0,0 +1,232 @@ +@import("../render/display_list.zag") +@import("../layout/adaptive.zag") + +// These semantic tokens define the experimental showcase palette. They prove +// token routing while RFC 0007 remains pending; they are not a selected 1.0 +// visual direction. +enum SemanticColorToken { + canvas, + surface_base, + surface_panel, + surface_raised, + surface_inset, + surface_interactive, + surface_selected, + border_subtle, + border_emphasis, + text_primary, + text_secondary, + text_disabled, + accent, + accent_emphasis, + focus, + status_healthy, + status_experimental, + category_semantics, + category_motion, + category_input, + category_renderer, + chart_grid, + chart_axis, + chart_series_primary, + chart_series_secondary, + chart_series_tertiary, + shadow, + edge_highlight, + ambient_cool, + ambient_warm, +} + +struct ResolvedColorToken { + id: []u8, + paint: Paint, +} + +fn semantic_color_token_count() i32 { return 30; } + +fn semantic_color(token: SemanticColorToken) ResolvedColorToken { + return switch (token) { + .canvas => ResolvedColorToken{ .id = "color.canvas", + .paint = paint_rgba16(2200, 3100, 6500, 65535) }, + .surface_base => ResolvedColorToken{ .id = "color.surface.base", + .paint = paint_rgba16(7200, 10500, 19000, 50000) }, + .surface_panel => ResolvedColorToken{ .id = "color.surface.panel", + .paint = paint_rgba16(7000, 12000, 22500, 56000) }, + .surface_raised => ResolvedColorToken{ .id = "color.surface.raised", + .paint = paint_rgba16(9000, 15500, 27500, 60000) }, + .surface_inset => ResolvedColorToken{ .id = "color.surface.inset", + .paint = paint_rgba16(4500, 7600, 14500, 56000) }, + .surface_interactive => ResolvedColorToken{ .id = "color.surface.interactive", + .paint = paint_rgba16(9000, 18000, 34000, 56000) }, + .surface_selected => ResolvedColorToken{ .id = "color.surface.selected", + .paint = paint_rgba16(12500, 36000, 59000, 56000) }, + .border_subtle => ResolvedColorToken{ .id = "color.border.subtle", + .paint = paint_rgba16(14000, 22000, 36000, 18000) }, + .border_emphasis => ResolvedColorToken{ .id = "color.border.emphasis", + .paint = paint_rgba16(32000, 47000, 65535, 21000) }, + .text_primary => ResolvedColorToken{ .id = "color.text.primary", + .paint = paint_rgba16(57000, 61000, 65535, 65535) }, + .text_secondary => ResolvedColorToken{ .id = "color.text.secondary", + .paint = paint_rgba16(35000, 43000, 56000, 60000) }, + .text_disabled => ResolvedColorToken{ .id = "color.text.disabled", + .paint = paint_rgba16(28000, 34000, 43000, 43000) }, + .accent => ResolvedColorToken{ .id = "color.accent", + .paint = paint_rgba16(17000, 48500, 65535, 62000) }, + .accent_emphasis => ResolvedColorToken{ .id = "color.accent.emphasis", + .paint = paint_rgba16(12500, 38500, 61000, 65535) }, + .focus => ResolvedColorToken{ .id = "color.focus", + .paint = paint_rgba16(26000, 60000, 65535, 65535) }, + .status_healthy => ResolvedColorToken{ .id = "color.status.healthy", + .paint = paint_rgba16(18000, 56000, 42000, 65535) }, + .status_experimental => ResolvedColorToken{ .id = "color.status.experimental", + .paint = paint_rgba16(65535, 43000, 17000, 65535) }, + .category_semantics => ResolvedColorToken{ .id = "color.category.semantics", + .paint = paint_rgba16(15000, 48000, 65535, 65535) }, + .category_motion => ResolvedColorToken{ .id = "color.category.motion", + .paint = paint_rgba16(30000, 43000, 65535, 65535) }, + .category_input => ResolvedColorToken{ .id = "color.category.input", + .paint = paint_rgba16(39000, 50000, 65535, 65535) }, + .category_renderer => ResolvedColorToken{ .id = "color.category.renderer", + .paint = paint_rgba16(44000, 39000, 65535, 65535) }, + .chart_grid => ResolvedColorToken{ .id = "color.chart.grid", + .paint = paint_rgba16(14000, 22000, 36000, 13000) }, + .chart_axis => ResolvedColorToken{ .id = "color.chart.axis", + .paint = paint_rgba16(30000, 38000, 50000, 42000) }, + .chart_series_primary => ResolvedColorToken{ .id = "color.chart.series.primary", + .paint = paint_rgba16(12000, 47000, 65535, 56000) }, + .chart_series_secondary => ResolvedColorToken{ .id = "color.chart.series.secondary", + .paint = paint_rgba16(41000, 36000, 65535, 59000) }, + .chart_series_tertiary => ResolvedColorToken{ .id = "color.chart.series.tertiary", + .paint = paint_rgba16(65535, 43000, 17000, 59000) }, + .shadow => ResolvedColorToken{ .id = "color.shadow", + .paint = paint_rgba16(0, 0, 0, 12000) }, + .edge_highlight => ResolvedColorToken{ .id = "color.edge.highlight", + .paint = paint_rgba16(42000, 56000, 65535, 19000) }, + .ambient_cool => ResolvedColorToken{ .id = "color.ambient.cool", + .paint = paint_rgba16(13000, 39000, 65535, 8000) }, + .ambient_warm => ResolvedColorToken{ .id = "color.ambient.warm", + .paint = paint_rgba16(58000, 18000, 47000, 7000) }, + }; +} + +fn semantic_color_paint(token: SemanticColorToken) Paint { + return semantic_color(token).paint; +} + +fn semantic_color_with_alpha(token: SemanticColorToken, alpha: i64) Paint { + let paint: Paint = semantic_color_paint(token); + paint.alpha = display_color_channel(alpha); + return paint; +} + +enum SemanticTypeToken { display, title, heading, body, label, caption, code } + +fn semantic_type_token_count() i32 { return 7; } + +fn semantic_type_name(token: SemanticTypeToken) []u8 { + return switch (token) { + .display => "type.display", .title => "type.title", + .heading => "type.heading", .body => "type.body", + .label => "type.label", .caption => "type.caption", + .code => "type.code", + }; +} + +fn semantic_type_size(token: SemanticTypeToken) i64 { + return switch (token) { + .display => 32 * unit_scale(), .title => 24 * unit_scale(), + .heading => 20 * unit_scale(), .body => 15 * unit_scale(), + .label => 14 * unit_scale(), .caption => 12 * unit_scale(), + .code => 13 * unit_scale(), + }; +} + +enum SemanticRadiusToken { control, card, panel, pill } + +fn semantic_radius_name(token: SemanticRadiusToken) []u8 { + return switch (token) { + .control => "radius.control", .card => "radius.card", + .panel => "radius.panel", .pill => "radius.pill", + }; +} + +fn semantic_radius(token: SemanticRadiusToken) i64 { + return switch (token) { + .control => 12 * unit_scale(), .card => 18 * unit_scale(), + .panel => 24 * unit_scale(), .pill => 999 * unit_scale(), + }; +} + +enum SemanticElevationToken { base, panel, raised, overlay } + +struct ResolvedElevationToken { + id: []u8, + shadow_offset_y: i64, + shadow_spread: i64, + shadow_alpha: i64, + border_alpha: i64, +} + +fn semantic_elevation(token: SemanticElevationToken) ResolvedElevationToken { + return switch (token) { + .base => ResolvedElevationToken{ .id = "elevation.base", + .shadow_offset_y = 0, .shadow_spread = 0, + .shadow_alpha = 0, .border_alpha = 9000 }, + .panel => ResolvedElevationToken{ .id = "elevation.panel", + .shadow_offset_y = 4 * unit_scale(), .shadow_spread = 3 * unit_scale(), + .shadow_alpha = 9000, .border_alpha = 13000 }, + .raised => ResolvedElevationToken{ .id = "elevation.raised", + .shadow_offset_y = 7 * unit_scale(), .shadow_spread = 5 * unit_scale(), + .shadow_alpha = 13000, .border_alpha = 18000 }, + .overlay => ResolvedElevationToken{ .id = "elevation.overlay", + .shadow_offset_y = 12 * unit_scale(), .shadow_spread = 8 * unit_scale(), + .shadow_alpha = 18000, .border_alpha = 23000 }, + }; +} + +enum ShowcaseCategory { frame_pacing, semantics, motion, input, renderer } + +fn showcase_category_at(index: i32) ShowcaseCategory { + if (index == 1) { return ShowcaseCategory.semantics; } + if (index == 2) { return ShowcaseCategory.motion; } + if (index == 3) { return ShowcaseCategory.input; } + if (index == 4) { return ShowcaseCategory.renderer; } + return ShowcaseCategory.frame_pacing; +} + +fn showcase_category_name(category: ShowcaseCategory) []u8 { + return switch (category) { + .frame_pacing => "Frame pacing", .semantics => "Semantics", + .motion => "Motion", .input => "Input", .renderer => "Renderer", + }; +} + +fn showcase_category_description(category: ShowcaseCategory) []u8 { + return switch (category) { + .frame_pacing => "Frame deadline and idle health", + .semantics => "Semantic tree and automation health", + .motion => "Animation scheduler health", + .input => "Input router and focus health", + .renderer => "CPU renderer and backend health", + }; +} + +fn showcase_category_value(category: ShowcaseCategory) []u8 { + return switch (category) { + .frame_pacing => "Experimental · gate pending", + .semantics => "38 nodes · stable IDs", + .motion => "Clock contract · live pending", + .input => "Pointer · keyboard pending", + .renderer => "CPU oracle · X11 fallback", + }; +} + +fn showcase_category_color(category: ShowcaseCategory) SemanticColorToken { + return switch (category) { + .frame_pacing => SemanticColorToken.status_experimental, + .semantics => SemanticColorToken.category_semantics, + .motion => SemanticColorToken.category_motion, + .input => SemanticColorToken.category_input, + .renderer => SemanticColorToken.category_renderer, + }; +} diff --git a/src/showcase/linux_preview_scene.zag b/src/showcase/linux_preview_scene.zag index de3f263..8a8ad2e 100644 --- a/src/showcase/linux_preview_scene.zag +++ b/src/showcase/linux_preview_scene.zag @@ -1,5 +1,6 @@ @import("../render/cpu_raster.zag") @import("../text/glyph_run.zag") +@import("../design/tokens.zag") fn preview_fx(value: i64) i64 { return value * unit_scale(); } @@ -18,6 +19,43 @@ fn preview_scale(value: i64, width: i32, height: i32) i64 { return y; } +fn preview_type(token: SemanticTypeToken, width: i32, height: i32) i64 { + return preview_scale(semantic_type_size(token) / unit_scale(), width, height); +} + +fn preview_radius(token: SemanticRadiusToken, width: i32, height: i32) i64 { + return preview_scale(semantic_radius(token) / unit_scale(), width, height); +} + +fn preview_background_band_paint(band: i32) Paint { + // The banding is a deterministic CPU fallback for a future gradient op. + // Its endpoints are local effect variants of the semantic canvas token. + let canvas: Paint = semantic_color_paint(SemanticColorToken.canvas); + return paint_rgba16(canvas.red + 200 + (band as i64) * 45, + canvas.green + 400 + (band as i64) * 70, + canvas.blue + 1100 + (band as i64) * 125, 65535); +} + +fn preview_navigation_name(index: i32) []u8 { + if (index == 1) { return "Components"; } + if (index == 2) { return "Motion"; } + if (index == 3) { return "Typography"; } + if (index == 4) { return "Talkback"; } + if (index == 5) { return "Settings"; } + return "Overview"; +} + +fn preview_chip_name(index: i32) []u8 { + if (index == 1) { return "Layout"; } + if (index == 2) { return "Motion"; } + if (index == 3) { return "Render"; } + return "State"; +} + +fn preview_category(index: i32) ShowcaseCategory { + return showcase_category_at(index); +} + fn preview_push_rect(list: *DisplayList, id: i64, bounds: Rect, paint: Paint) i32 { let op: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(id)); @@ -104,24 +142,28 @@ fn preview_circle(list: *DisplayList, id: i64, center_x: i64, fn preview_glass_panel(list: *DisplayList, id: i64, x: i64, y: i64, width: i64, height: i64, radius: i64) void { - let spread: i64 = 5 * unit_scale(); - _ = preview_round_rect(list, id, x - spread, y + spread, + let elevation: ResolvedElevationToken = semantic_elevation( + SemanticElevationToken.panel); + let spread: i64 = elevation.shadow_spread; + _ = preview_round_rect(list, id, x - spread, y + elevation.shadow_offset_y, width + spread * 2, height + spread * 2, radius + spread, - paint_rgba16(0, 0, 0, 9000)); + semantic_color_with_alpha(SemanticColorToken.shadow, + elevation.shadow_alpha)); _ = preview_round_rect(list, id + 1, x, y, width, height, radius, - paint_rgba16(7200, 10500, 19000, 50000)); + semantic_color_paint(SemanticColorToken.surface_base)); _ = preview_round_rect(list, id + 2, x + unit_scale(), y + unit_scale(), width - 2 * unit_scale(), 2 * unit_scale(), radius, - paint_rgba16(42000, 56000, 65535, 17000)); + semantic_color_with_alpha(SemanticColorToken.edge_highlight, + elevation.border_alpha)); } fn linux_preview_scene_internal(width: i32, height: i32, - face: OpenTypeFace, has_font: i32) DisplayList { + face: OpenTypeFace, has_font: i32, selected_navigation: i32) DisplayList { let list: DisplayList = display_list_make(); let full: Rect = rect(0, 0, (width as i64) * unit_scale(), (height as i64) * unit_scale()); _ = preview_push_rect(&list, 1, full, - paint_rgba16(2200, 3100, 6500, 65535)); + semantic_color_paint(SemanticColorToken.canvas)); // A deterministic deep-space gradient made of retained bands. It is not a // shader approximation, so the CPU oracle remains the exact visual truth. @@ -129,13 +171,10 @@ fn linux_preview_scene_internal(width: i32, height: i32, while (band < 48) { let y0: i64 = ((height as i64) * (band as i64)) / 48; let y1: i64 = ((height as i64) * ((band + 1) as i64)) / 48; - let red: i64 = 2400 + (band as i64) * 45; - let green: i64 = 3500 + (band as i64) * 70; - let blue: i64 = 7600 + (band as i64) * 125; _ = preview_push_rect(&list, 100 + band as i64, rect(0, y0 * unit_scale(), (width as i64) * unit_scale(), (y1 - y0 + 1) * unit_scale()), - paint_rgba16(red, green, blue, 65535)); + preview_background_band_paint(band)); band = band + 1; } @@ -144,7 +183,7 @@ fn linux_preview_scene_internal(width: i32, height: i32, let radius: i64 = preview_x(210 - (glow_step as i64) * 17, width); _ = preview_circle(&list, 300 + glow_step as i64, preview_x(850, width), preview_y(122, height), radius, - paint_rgba16(13000, 39000, 65535, + semantic_color_with_alpha(SemanticColorToken.ambient_cool, 1700 + (glow_step as i64) * 900)); glow_step = glow_step + 1; } @@ -153,61 +192,68 @@ fn linux_preview_scene_internal(width: i32, height: i32, let radius: i64 = preview_x(160 - (warm_step as i64) * 18, width); _ = preview_circle(&list, 330 + warm_step as i64, preview_x(1035, width), preview_y(650, height), radius, - paint_rgba16(58000, 18000, 47000, + semantic_color_with_alpha(SemanticColorToken.ambient_warm, 1200 + (warm_step as i64) * 850)); warm_step = warm_step + 1; } preview_glass_panel(&list, 400, preview_x(24, width), preview_y(20, height), - preview_x(1072, width), preview_y(64, height), preview_x(18, width)); + preview_x(1072, width), preview_y(64, height), + preview_radius(SemanticRadiusToken.card, width, height)); preview_glass_panel(&list, 410, preview_x(24, width), preview_y(104, height), - preview_x(212, width), preview_y(592, height), preview_x(24, width)); + preview_x(212, width), preview_y(592, height), + preview_radius(SemanticRadiusToken.panel, width, height)); preview_glass_panel(&list, 420, preview_x(256, width), preview_y(104, height), - preview_x(548, width), preview_y(592, height), preview_x(28, width)); + preview_x(548, width), preview_y(592, height), + preview_radius(SemanticRadiusToken.panel, width, height)); preview_glass_panel(&list, 430, preview_x(824, width), preview_y(104, height), - preview_x(272, width), preview_y(592, height), preview_x(24, width)); + preview_x(272, width), preview_y(592, height), + preview_radius(SemanticRadiusToken.panel, width, height)); // Brand mark and quiet title rhythm. _ = preview_circle(&list, 500, preview_x(58, width), preview_y(52, height), - preview_x(15, width), paint_rgba16(18000, 52000, 65535, 65535)); + preview_x(15, width), semantic_color_paint(SemanticColorToken.accent)); _ = preview_circle(&list, 501, preview_x(62, width), preview_y(48, height), - preview_x(7, width), paint_rgba16(52000, 63000, 65535, 56000)); + preview_x(7, width), semantic_color_with_alpha( + SemanticColorToken.edge_highlight, 56000)); if (has_font == 0) { _ = preview_round_rect(&list, 502, preview_x(88, width), preview_y(39, height), preview_x(128, width), preview_y(12, height), preview_x(6, width), - paint_rgba16(52000, 58000, 65000, 60000)); + semantic_color_paint(SemanticColorToken.text_primary)); _ = preview_round_rect(&list, 503, preview_x(88, width), preview_y(58, height), preview_x(76, width), preview_y(7, height), preview_x(4, width), - paint_rgba16(25000, 34000, 50000, 50000)); + semantic_color_paint(SemanticColorToken.text_secondary)); } _ = preview_round_rect(&list, 504, preview_x(935, width), preview_y(36, height), preview_x(132, width), preview_y(32, height), preview_x(16, width), - paint_rgba16(12500, 38500, 61000, 58000)); + semantic_color_paint(SemanticColorToken.accent_emphasis)); // Left navigation: stable spacing, selected state, and aligned glyph marks. let nav: i32 = 0; while (nav < 6) { let ny: i64 = preview_y(142 + (nav as i64) * 70, height); - if (nav == 1) { + if (nav == selected_navigation) { _ = preview_round_rect(&list, 600 + nav as i64, preview_x(42, width), ny - preview_y(13, height), preview_x(176, width), preview_y(48, height), preview_x(15, width), - paint_rgba16(12500, 36000, 59000, 50000)); + semantic_color_paint(SemanticColorToken.surface_selected)); } + let nav_mark_alpha: i64 = 42000; + if (nav == selected_navigation) { nav_mark_alpha = 65535; } _ = preview_round_rect(&list, 620 + nav as i64, preview_x(58, width), ny, preview_x(20, width), preview_y(20, height), - preview_x(7, width), paint_rgba16( - 18000 + (nav as i64) * 1800, - 35000 + (nav as i64) * 1900, 56000, 52000)); + preview_x(7, width), semantic_color_with_alpha( + SemanticColorToken.accent, nav_mark_alpha)); if (has_font == 0) { _ = preview_round_rect(&list, 640 + nav as i64, preview_x(94, width), ny + preview_y(4, height), preview_x(86 + ((nav % 3) as i64) * 18, width), preview_y(10, height), - preview_x(5, width), paint_rgba16(33000, 41000, 53500, 48000)); + preview_x(5, width), semantic_color_paint( + SemanticColorToken.text_secondary)); } nav = nav + 1; } @@ -215,28 +261,28 @@ fn linux_preview_scene_internal(width: i32, height: i32, // Main product card with a continuous path visualization. _ = preview_round_rect(&list, 700, preview_x(284, width), preview_y(132, height), preview_x(492, width), preview_y(72, height), preview_x(20, width), - paint_rgba16(9000, 15500, 27500, 52000)); + semantic_color_paint(SemanticColorToken.surface_raised)); if (has_font == 0) { _ = preview_round_rect(&list, 701, preview_x(310, width), preview_y(151, height), preview_x(184, width), preview_y(14, height), preview_x(7, width), - paint_rgba16(52000, 57500, 64500, 60000)); + semantic_color_paint(SemanticColorToken.text_primary)); _ = preview_round_rect(&list, 702, preview_x(310, width), preview_y(175, height), preview_x(116, width), preview_y(8, height), preview_x(4, width), - paint_rgba16(24500, 33000, 47500, 50000)); + semantic_color_paint(SemanticColorToken.text_secondary)); } _ = preview_round_rect(&list, 703, preview_x(650, width), preview_y(148, height), preview_x(98, width), preview_y(34, height), preview_x(17, width), - paint_rgba16(17000, 48500, 65535, 58000)); + semantic_color_paint(SemanticColorToken.accent)); _ = preview_round_rect(&list, 710, preview_x(284, width), preview_y(224, height), preview_x(492, width), preview_y(282, height), preview_x(22, width), - paint_rgba16(4500, 7600, 14500, 48000)); + semantic_color_paint(SemanticColorToken.surface_inset)); let grid_line: i32 = 0; while (grid_line < 8) { _ = preview_push_rect(&list, 720 + grid_line as i64, rect(preview_x(310 + (grid_line as i64) * 60, width), preview_y(246, height), preview_x(1, width), preview_y(236, height)), - paint_rgba16(14000, 22000, 36000, 13000)); + semantic_color_paint(SemanticColorToken.chart_grid)); grid_line = grid_line + 1; } let horizontal: i32 = 0; @@ -244,28 +290,65 @@ fn linux_preview_scene_internal(width: i32, height: i32, _ = preview_push_rect(&list, 740 + horizontal as i64, rect(preview_x(308, width), preview_y(258 + (horizontal as i64) * 48, height), preview_x(442, width), preview_y(1, height)), - paint_rgba16(14000, 22000, 36000, 13000)); + semantic_color_paint(SemanticColorToken.chart_grid)); horizontal = horizontal + 1; } + // Explicit axes and a 16.7 ms frame-deadline reference distinguish this + // chart component from a decorative dot grid. + _ = preview_push_rect(&list, 750, + rect(preview_x(308, width), preview_y(246, height), preview_x(2, width), + preview_y(238, height)), + semantic_color_paint(SemanticColorToken.chart_axis)); + _ = preview_push_rect(&list, 751, + rect(preview_x(308, width), preview_y(482, height), preview_x(442, width), + preview_y(2, height)), + semantic_color_paint(SemanticColorToken.chart_axis)); + _ = preview_push_rect(&list, 752, + rect(preview_x(308, width), preview_y(354, height), preview_x(442, width), + preview_y(1, height)), + semantic_color_with_alpha(SemanticColorToken.border_emphasis, 30000)); let curve_point: i32 = 0; - while (curve_point < 25) { + while (curve_point < 24) { let px: i64 = 320 + (curve_point as i64) * 17; let phase: i64 = curve_point % 8; let py: i64 = 390 - phase * 12 + ((curve_point / 8) as i64) * 15; + let series_color: SemanticColorToken = SemanticColorToken.chart_series_primary; + if (curve_point >= 8) { + series_color = SemanticColorToken.chart_series_secondary; + } + if (curve_point >= 16) { + series_color = SemanticColorToken.chart_series_tertiary; + } _ = preview_circle(&list, 760 + curve_point as i64, preview_x(px, width), preview_y(py, height), preview_x(5, width), - paint_rgba16(12000, 47000, 65535, 47000)); + semantic_color_paint(series_color)); curve_point = curve_point + 1; } + _ = preview_circle(&list, 790, preview_x(580, width), preview_y(252, height), + preview_x(4, width), semantic_color_paint( + SemanticColorToken.chart_series_primary)); + _ = preview_circle(&list, 791, preview_x(644, width), preview_y(252, height), + preview_x(4, width), semantic_color_paint( + SemanticColorToken.chart_series_secondary)); + _ = preview_circle(&list, 792, preview_x(702, width), preview_y(252, height), + preview_x(4, width), semantic_color_paint( + SemanticColorToken.chart_series_tertiary)); // Bottom action strip and right-side activity cards. let chip: i32 = 0; while (chip < 4) { + let chip_paint: Paint = semantic_color_paint( + SemanticColorToken.surface_interactive); + if (chip == 0) { + chip_paint = semantic_color_paint(SemanticColorToken.surface_selected); + } else if (chip == 3) { + chip_paint = semantic_color_with_alpha( + SemanticColorToken.surface_interactive, 32000); + } _ = preview_round_rect(&list, 800 + chip as i64, preview_x(284 + (chip as i64) * 124, width), preview_y(530, height), preview_x(108, width), preview_y(42, height), preview_x(16, width), - paint_rgba16(9000 + (chip as i64) * 2200, - 18000 + (chip as i64) * 2500, 34000 + (chip as i64) * 3200, 50000)); + chip_paint); chip = chip + 1; } let activity: i32 = 0; @@ -274,20 +357,20 @@ fn linux_preview_scene_internal(width: i32, height: i32, _ = preview_round_rect(&list, 840 + activity as i64, preview_x(848, width), preview_y(ay, height), preview_x(224, width), preview_y(74, height), preview_x(18, width), - paint_rgba16(7000, 12000, 22500, 47000)); + semantic_color_paint(SemanticColorToken.surface_panel)); + let category: ShowcaseCategory = preview_category(activity); _ = preview_circle(&list, 860 + activity as i64, preview_x(876, width), preview_y(ay + 27, height), preview_x(11, width), - paint_rgba16(15000 + (activity as i64) * 6000, - 51000 - (activity as i64) * 3500, 62000, 56000)); + semantic_color_paint(showcase_category_color(category))); if (has_font == 0) { _ = preview_round_rect(&list, 880 + activity as i64, preview_x(900, width), preview_y(ay + 17, height), preview_x(126, width), preview_y(10, height), preview_x(5, width), - paint_rgba16(41000, 48500, 59000, 50000)); + semantic_color_paint(SemanticColorToken.text_primary)); _ = preview_round_rect(&list, 900 + activity as i64, preview_x(900, width), preview_y(ay + 38, height), preview_x(88, width), preview_y(7, height), preview_x(4, width), - paint_rgba16(22000, 30000, 44000, 43000)); + semantic_color_paint(SemanticColorToken.text_secondary)); } activity = activity + 1; } @@ -295,59 +378,80 @@ fn linux_preview_scene_internal(width: i32, height: i32, _ = preview_push_stroke(&list, 950, rect(preview_x(24, width), preview_y(20, height), preview_x(1072, width), preview_y(64, height)), - paint_rgba16(32000, 47000, 65535, 15000), unit_scale()); + semantic_color_paint(SemanticColorToken.border_emphasis), unit_scale()); if (has_font != 0) { - let title_size: i64 = preview_scale(22, width, height); - let body_size: i64 = preview_scale(15, width, height); - let quiet: Paint = paint_rgba16(35000, 43000, 56000, 60000); - let bright: Paint = paint_rgba16(57000, 61000, 65535, 65535); + let title_size: i64 = preview_type(SemanticTypeToken.title, width, height); + let body_size: i64 = preview_type(SemanticTypeToken.body, width, height); + let quiet: Paint = semantic_color_paint(SemanticColorToken.text_secondary); + let bright: Paint = semantic_color_paint(SemanticColorToken.text_primary); _ = preview_add_text(&list, face, 10000, "Zagkit Studio", title_size, preview_x(88, width), preview_y(61, height), bright); _ = preview_add_text(&list, face, 10100, "Preview", body_size, preview_x(968, width), preview_y(58, height), bright); - _ = preview_add_text(&list, face, 10200, "Overview", body_size, - preview_x(94, width), preview_y(158, height), quiet); - _ = preview_add_text(&list, face, 10300, "Components", body_size, - preview_x(94, width), preview_y(228, height), bright); - _ = preview_add_text(&list, face, 10400, "Motion", body_size, - preview_x(94, width), preview_y(298, height), quiet); - _ = preview_add_text(&list, face, 10500, "Typography", body_size, - preview_x(94, width), preview_y(368, height), quiet); - _ = preview_add_text(&list, face, 10600, "Talkback", body_size, - preview_x(94, width), preview_y(438, height), quiet); - _ = preview_add_text(&list, face, 10700, "Settings", body_size, - preview_x(94, width), preview_y(508, height), quiet); - _ = preview_add_text(&list, face, 10800, "Typography pipeline", - preview_scale(20, width, height), preview_x(310, width), + let nav_text: i32 = 0; + while (nav_text < 6) { + let nav_paint: Paint = quiet; + if (nav_text == selected_navigation) { nav_paint = bright; } + _ = preview_add_text(&list, face, 10200 + (nav_text as i64) * 100, + preview_navigation_name(nav_text), body_size, + preview_x(94, width), preview_y(158 + (nav_text as i64) * 70, height), + nav_paint); + nav_text = nav_text + 1; + } + _ = preview_add_text(&list, face, 10800, "Renderer performance", + preview_type(SemanticTypeToken.heading, width, height), preview_x(310, width), preview_y(170, height), bright); - _ = preview_add_text(&list, face, 10900, "Owned contours. Deterministic CPU.", - preview_scale(13, width, height), preview_x(310, width), + _ = preview_add_text(&list, face, 10900, "CPU oracle. Cold, warm, and resize samples.", + preview_type(SemanticTypeToken.code, width, height), preview_x(310, width), preview_y(190, height), quiet); _ = preview_add_text(&list, face, 11000, "Inspect", - preview_scale(14, width, height), preview_x(670, width), + preview_type(SemanticTypeToken.label, width, height), preview_x(670, width), preview_y(171, height), bright); - _ = preview_add_text(&list, face, 11100, "State", - preview_scale(14, width, height), preview_x(318, width), - preview_y(557, height), bright); - _ = preview_add_text(&list, face, 11200, "Layout", - preview_scale(14, width, height), preview_x(437, width), - preview_y(557, height), bright); - _ = preview_add_text(&list, face, 11300, "Motion", - preview_scale(14, width, height), preview_x(559, width), - preview_y(557, height), bright); - _ = preview_add_text(&list, face, 11400, "Render", - preview_scale(14, width, height), preview_x(682, width), - preview_y(557, height), bright); - _ = preview_add_text(&list, face, 11500, "Frame pacing", - body_size, preview_x(900, width), preview_y(169, height), bright); - _ = preview_add_text(&list, face, 11600, "Semantics", - body_size, preview_x(900, width), preview_y(265, height), bright); - _ = preview_add_text(&list, face, 11700, "Motion", - body_size, preview_x(900, width), preview_y(361, height), bright); - _ = preview_add_text(&list, face, 11800, "Input", - body_size, preview_x(900, width), preview_y(457, height), bright); - _ = preview_add_text(&list, face, 11900, "Renderer", - body_size, preview_x(900, width), preview_y(553, height), bright); + let caption_size: i64 = preview_type(SemanticTypeToken.caption, width, height); + _ = preview_add_text(&list, face, 12000, "24 ms", caption_size, + preview_x(268, width), preview_y(262, height), quiet); + _ = preview_add_text(&list, face, 12100, "16.7", caption_size, + preview_x(270, width), preview_y(359, height), quiet); + _ = preview_add_text(&list, face, 12200, "0 ms", caption_size, + preview_x(274, width), preview_y(484, height), quiet); + _ = preview_add_text(&list, face, 12300, "sample", caption_size, + preview_x(704, width), preview_y(500, height), quiet); + _ = preview_add_text(&list, face, 12400, "cold", caption_size, + preview_x(588, width), preview_y(256, height), quiet); + _ = preview_add_text(&list, face, 12500, "warm", caption_size, + preview_x(652, width), preview_y(256, height), quiet); + _ = preview_add_text(&list, face, 12600, "resize", caption_size, + preview_x(710, width), preview_y(256, height), quiet); + let chip_text: i32 = 0; + while (chip_text < 4) { + let chip_text_paint: Paint = quiet; + if (chip_text == 0) { chip_text_paint = bright; } + else if (chip_text == 3) { + chip_text_paint = semantic_color_paint( + SemanticColorToken.text_disabled); + } + _ = preview_add_text(&list, face, 11100 + (chip_text as i64) * 100, + preview_chip_name(chip_text), + preview_type(SemanticTypeToken.label, width, height), + preview_x(318 + (chip_text as i64) * 124, width), + preview_y(557, height), chip_text_paint); + chip_text = chip_text + 1; + } + let activity_text: i32 = 0; + _ = preview_add_text(&list, face, 11450, "SYSTEM STATUS", + caption_size, preview_x(848, width), preview_y(126, height), quiet); + while (activity_text < 5) { + let category: ShowcaseCategory = preview_category(activity_text); + _ = preview_add_text(&list, face, 11500 + (activity_text as i64) * 100, + showcase_category_name(category), body_size, + preview_x(900, width), + preview_y(169 + (activity_text as i64) * 96, height), bright); + _ = preview_add_text(&list, face, 13000 + (activity_text as i64) * 100, + showcase_category_value(category), caption_size, + preview_x(900, width), + preview_y(190 + (activity_text as i64) * 96, height), quiet); + activity_text = activity_text + 1; + } } _ = display_list_seal(&list); return list; @@ -355,12 +459,17 @@ fn linux_preview_scene_internal(width: i32, height: i32, fn linux_preview_scene(width: i32, height: i32) DisplayList { let empty: OpenTypeFace = opentype_face_empty(); - let list: DisplayList = linux_preview_scene_internal(width, height, empty, 0); + let list: DisplayList = linux_preview_scene_internal(width, height, empty, 0, 1); opentype_face_free(&empty); return list; } fn linux_preview_scene_with_font(width: i32, height: i32, face: OpenTypeFace) DisplayList { - return linux_preview_scene_internal(width, height, face, 1); + return linux_preview_scene_internal(width, height, face, 1, 1); +} + +fn linux_preview_scene_with_font_state(width: i32, height: i32, + face: OpenTypeFace, selected_navigation: i32) DisplayList { + return linux_preview_scene_internal(width, height, face, 1, selected_navigation); } diff --git a/src/showcase/linux_preview_semantics.zag b/src/showcase/linux_preview_semantics.zag new file mode 100644 index 0000000..184cd90 --- /dev/null +++ b/src/showcase/linux_preview_semantics.zag @@ -0,0 +1,208 @@ +@import("../semantics/semantics.zag") +@import("../design/tokens.zag") + +fn preview_semantics_x(value: i64, width: i32) i64 { + return (value * (width as i64) * unit_scale()) / 1120; +} + +fn preview_semantics_y(value: i64, height: i32) i64 { + return (value * (height as i64) * unit_scale()) / 720; +} + +fn linux_preview_navigation_at_pixel(x: i32, y: i32, + width: i32, height: i32) i32 { + if (width <= 0 || height <= 0) { return 0 - 1; } + let logical_x: i64 = (x as i64) * 1120 / width; + let logical_y: i64 = (y as i64) * 720 / height; + if (logical_x < 42 || logical_x >= 218) { return 0 - 1; } + let index: i32 = 0; + while (index < 6) { + let top: i64 = 129 + (index as i64) * 70; + if (logical_y >= top && logical_y < top + 48) { return index; } + index = index + 1; + } + return 0 - 1; +} + +fn linux_preview_root_id() NodeKey { return node_key(20000); } +fn linux_preview_navigation_id() NodeKey { return node_key(20100); } +fn linux_preview_nav_id(index: i32) NodeKey { return node_key(20200 + index); } +fn linux_preview_main_id() NodeKey { return node_key(20300); } +fn linux_preview_inspect_id() NodeKey { return node_key(20302); } +fn linux_preview_chip_id(index: i32) NodeKey { return node_key(20400 + index); } +fn linux_preview_activity_id() NodeKey { return node_key(20500); } +fn linux_preview_activity_item_id(index: i32) NodeKey { return node_key(20600 + index); } +fn linux_preview_chart_table_id() NodeKey { return node_key(20700); } +fn linux_preview_chart_cell_id(index: i32) NodeKey { return node_key(20710 + index); } +fn linux_preview_semantic_node_count() i32 { return 38; } + +fn linux_preview_chart_sample_name(index: i32) []u8 { + if (index == 1) { return "Sample 2"; } + if (index == 2) { return "Sample 3"; } + if (index == 3) { return "Sample 4"; } + if (index == 4) { return "Sample 5"; } + if (index == 5) { return "Sample 6"; } + if (index == 6) { return "Sample 7"; } + if (index == 7) { return "Sample 8"; } + return "Sample 1"; +} + +fn linux_preview_chart_sample_value(index: i32) []u8 { + if (index == 1) { return "18.8 milliseconds"; } + if (index == 2) { return "17.4 milliseconds"; } + if (index == 3) { return "15.9 milliseconds"; } + if (index == 4) { return "14.7 milliseconds"; } + if (index == 5) { return "13.3 milliseconds"; } + if (index == 6) { return "12.1 milliseconds"; } + if (index == 7) { return "10.8 milliseconds"; } + return "20.2 milliseconds"; +} + +fn linux_preview_add_semantic(tree: *SemanticsTree, spec: SemanticsSpec) i32 { + return (semantics_add(tree, spec) == SemanticsError.none) as i32; +} + +fn linux_preview_semantics(width: i32, height: i32, + selected_navigation: i32) SemanticsTree { + let tree: SemanticsTree = semantics_tree_make(); + let root: SemanticsSpec = semantics_spec(linux_preview_root_id(), + semantic_root_key(), SemanticRole.window, "Zagkit Studio Linux Preview"); + root.description = "Experimental native Zagkit component and renderer preview"; + root.has_bounds = 1; + root.bounds = rect(0, 0, (width as i64) * unit_scale(), + (height as i64) * unit_scale()); + _ = linux_preview_add_semantic(&tree, root); + + let navigation: SemanticsSpec = semantics_spec(linux_preview_navigation_id(), + linux_preview_root_id(), SemanticRole.group, "Primary navigation"); + navigation.has_bounds = 1; + navigation.bounds = rect(preview_semantics_x(24, width), + preview_semantics_y(104, height), preview_semantics_x(212, width), + preview_semantics_y(592, height)); + _ = linux_preview_add_semantic(&tree, navigation); + let nav_index: i32 = 0; + while (nav_index < 6) { + let name: []u8 = "Overview"; + if (nav_index == 1) { name = "Components"; } + else if (nav_index == 2) { name = "Motion"; } + else if (nav_index == 3) { name = "Typography"; } + else if (nav_index == 4) { name = "Talkback"; } + else if (nav_index == 5) { name = "Settings"; } + let tab: SemanticsSpec = semantics_spec(linux_preview_nav_id(nav_index), + linux_preview_navigation_id(), SemanticRole.tab, name); + tab.action_mask = semantic_actions_add( + semantic_actions_add(semantic_actions(SemanticAction.activate), + SemanticAction.select), SemanticAction.focus); + tab.focus_order = nav_index + 1; + tab.selected = (nav_index == selected_navigation) as i32; + tab.set_size = 6; tab.position_in_set = nav_index + 1; + tab.has_bounds = 1; + tab.bounds = rect(preview_semantics_x(42, width), + preview_semantics_y(129 + (nav_index as i64) * 70, height), + preview_semantics_x(176, width), preview_semantics_y(48, height)); + _ = linux_preview_add_semantic(&tree, tab); + nav_index = nav_index + 1; + } + + let main: SemanticsSpec = semantics_spec(linux_preview_main_id(), + linux_preview_root_id(), SemanticRole.group, "Renderer performance"); + main.description = "Deterministic CPU frame-time chart and pipeline controls"; + main.has_bounds = 1; + main.bounds = rect(preview_semantics_x(256, width), + preview_semantics_y(104, height), preview_semantics_x(548, width), + preview_semantics_y(592, height)); + _ = linux_preview_add_semantic(&tree, main); + let heading: SemanticsSpec = semantics_spec(node_key(20301), + linux_preview_main_id(), SemanticRole.text, "Renderer performance"); + heading.has_bounds = 1; + heading.bounds = rect(preview_semantics_x(310, width), + preview_semantics_y(145, height), preview_semantics_x(260, width), + preview_semantics_y(50, height)); + _ = linux_preview_add_semantic(&tree, heading); + let inspect: SemanticsSpec = semantics_spec(linux_preview_inspect_id(), + linux_preview_main_id(), SemanticRole.button, "Inspect renderer pipeline"); + inspect.action_mask = semantic_actions_add(semantic_actions(SemanticAction.activate), + SemanticAction.focus); + inspect.focus_order = 7; inspect.has_bounds = 1; + inspect.bounds = rect(preview_semantics_x(650, width), + preview_semantics_y(148, height), preview_semantics_x(98, width), + preview_semantics_y(34, height)); + _ = linux_preview_add_semantic(&tree, inspect); + + let chip_index: i32 = 0; + while (chip_index < 4) { + let name: []u8 = "State"; + if (chip_index == 1) { name = "Layout"; } + else if (chip_index == 2) { name = "Motion"; } + else if (chip_index == 3) { name = "Render"; } + let chip: SemanticsSpec = semantics_spec(linux_preview_chip_id(chip_index), + linux_preview_main_id(), SemanticRole.tab, name); + chip.action_mask = semantic_actions_add(semantic_actions(SemanticAction.activate), + SemanticAction.focus); + chip.focus_order = 8 + chip_index; chip.has_bounds = 1; + chip.selected = (chip_index == 0) as i32; + chip.set_size = 4; chip.position_in_set = chip_index + 1; + if (chip_index == 3) { + chip.disabled = 1; chip.action_mask = 0; + } + chip.bounds = rect(preview_semantics_x(284 + (chip_index as i64) * 124, width), + preview_semantics_y(530, height), preview_semantics_x(108, width), + preview_semantics_y(42, height)); + _ = linux_preview_add_semantic(&tree, chip); + chip_index = chip_index + 1; + } + + let chart: SemanticsSpec = semantics_spec(linux_preview_chart_table_id(), + linux_preview_main_id(), SemanticRole.table, "CPU frame time samples"); + chart.description = "Frame time in milliseconds by sample; 16.7 millisecond deadline"; + chart.row_count = 8; chart.column_count = 2; chart.has_bounds = 1; + chart.bounds = rect(preview_semantics_x(284, width), + preview_semantics_y(224, height), preview_semantics_x(492, width), + preview_semantics_y(282, height)); + _ = linux_preview_add_semantic(&tree, chart); + let sample_index: i32 = 0; + while (sample_index < 8) { + let sample_cell: SemanticsSpec = semantics_spec( + linux_preview_chart_cell_id(sample_index * 2), + linux_preview_chart_table_id(), SemanticRole.cell, + linux_preview_chart_sample_name(sample_index)); + sample_cell.row_index = sample_index; sample_cell.column_index = 0; + sample_cell.row_span = 1; sample_cell.column_span = 1; + _ = linux_preview_add_semantic(&tree, sample_cell); + let value_cell: SemanticsSpec = semantics_spec( + linux_preview_chart_cell_id(sample_index * 2 + 1), + linux_preview_chart_table_id(), SemanticRole.cell, "Frame time"); + value_cell.value = linux_preview_chart_sample_value(sample_index); + value_cell.row_index = sample_index; value_cell.column_index = 1; + value_cell.row_span = 1; value_cell.column_span = 1; + _ = linux_preview_add_semantic(&tree, value_cell); + sample_index = sample_index + 1; + } + + let activity: SemanticsSpec = semantics_spec(linux_preview_activity_id(), + linux_preview_root_id(), SemanticRole.list, "System status"); + activity.row_count = 5; activity.column_count = 1; activity.has_bounds = 1; + activity.bounds = rect(preview_semantics_x(824, width), + preview_semantics_y(104, height), preview_semantics_x(272, width), + preview_semantics_y(592, height)); + _ = linux_preview_add_semantic(&tree, activity); + let activity_index: i32 = 0; + while (activity_index < 5) { + let category: ShowcaseCategory = showcase_category_at(activity_index); + let name: []u8 = showcase_category_name(category); + let item: SemanticsSpec = semantics_spec( + linux_preview_activity_item_id(activity_index), + linux_preview_activity_id(), SemanticRole.list_item, name); + item.description = showcase_category_description(category); + item.value = showcase_category_value(category); + item.row_index = activity_index; item.row_span = 1; + item.column_index = 0; item.column_span = 1; + item.has_bounds = 1; + item.bounds = rect(preview_semantics_x(848, width), + preview_semantics_y(138 + (activity_index as i64) * 96, height), + preview_semantics_x(224, width), preview_semantics_y(74, height)); + _ = linux_preview_add_semantic(&tree, item); + activity_index = activity_index + 1; + } + return tree; +} diff --git a/tests/design_tokens_contract.zag b/tests/design_tokens_contract.zag new file mode 100644 index 0000000..3bffa93 --- /dev/null +++ b/tests/design_tokens_contract.zag @@ -0,0 +1,74 @@ +@import("../src/design/tokens.zag") + +struct TestState { passed: i32, failed: i32 } +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; _zag_print("FAIL "); _zag_println(name); + } +} + +fn bytes_equal(left: []u8, right: []u8) i32 { + if (left.len != right.len) { return 0; } + let index: i32 = 0; + while (index < left.len) { + if (left[index] != right[index]) { return 0; } + index = index + 1; + } + return 1; +} + +fn paint_equal(left: Paint, right: Paint) i32 { + return (left.red == right.red && left.green == right.green && + left.blue == right.blue && left.alpha == right.alpha) as i32; +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + expect(&state, semantic_color_token_count() == 30 && + bytes_equal(semantic_color(SemanticColorToken.accent).id, "color.accent") != 0, + "semantic colors expose stable inspectable token IDs"); + expect(&state, paint_equal(semantic_color_paint(SemanticColorToken.accent), + semantic_color_paint(SemanticColorToken.chart_series_primary)) == 0, + "brand actions and data series are intentionally separate tokens"); + let faded: Paint = semantic_color_with_alpha(SemanticColorToken.accent, 12345); + expect(&state, faded.alpha == 12345 && faded.red == + semantic_color_paint(SemanticColorToken.accent).red, + "alpha variants preserve semantic color provenance"); + expect(&state, semantic_type_token_count() == 7 && + semantic_type_size(SemanticTypeToken.display) > + semantic_type_size(SemanticTypeToken.title) && + semantic_type_size(SemanticTypeToken.title) > + semantic_type_size(SemanticTypeToken.heading) && + semantic_type_size(SemanticTypeToken.heading) > + semantic_type_size(SemanticTypeToken.body), + "the typography ramp has explicit ordered roles"); + expect(&state, semantic_radius(SemanticRadiusToken.control) < + semantic_radius(SemanticRadiusToken.card) && + semantic_radius(SemanticRadiusToken.card) < + semantic_radius(SemanticRadiusToken.panel), + "control card and panel shapes are distinct semantic tiers"); + let base: ResolvedElevationToken = semantic_elevation(SemanticElevationToken.base); + let panel: ResolvedElevationToken = semantic_elevation(SemanticElevationToken.panel); + let raised: ResolvedElevationToken = semantic_elevation(SemanticElevationToken.raised); + let overlay: ResolvedElevationToken = semantic_elevation(SemanticElevationToken.overlay); + expect(&state, base.shadow_alpha < panel.shadow_alpha && + panel.shadow_alpha < raised.shadow_alpha && + raised.shadow_alpha < overlay.shadow_alpha, + "base panel raised and overlay elevations have ordered depth"); + expect(&state, bytes_equal(showcase_category_description( + ShowcaseCategory.frame_pacing), showcase_category_description( + ShowcaseCategory.semantics)) == 0 && + showcase_category_color(ShowcaseCategory.frame_pacing) != + showcase_category_color(ShowcaseCategory.semantics), + "showcase status marks have documented non-decorative meanings"); + expect(&state, flex_spacing(FlexSpacingToken.small, FlexDensity.standard) == + 8 * unit_scale() && flex_spacing(FlexSpacingToken.large, + FlexDensity.standard) == 16 * unit_scale(), + "showcase placement reuses the public Flex spacing scale"); + _zag_print("Design tokens: pass="); _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tests/linux_preview_semantics_contract.zag b/tests/linux_preview_semantics_contract.zag new file mode 100644 index 0000000..30ccbde --- /dev/null +++ b/tests/linux_preview_semantics_contract.zag @@ -0,0 +1,82 @@ +@import("../src/showcase/linux_preview_semantics.zag") +@import("../src/automation/talkback.zag") + +struct TestState { passed: i32, failed: i32 } +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; _zag_print("FAIL "); _zag_println(name); + } +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + let tree: SemanticsTree = linux_preview_semantics(1120, 720, 1); + expect(&state, tree.last_error == SemanticsError.none && + tree.nodes.len == linux_preview_semantic_node_count(), + "the native preview exposes one complete owned semantic tree"); + let components_index: i32 = semantics_find_index(tree, linux_preview_nav_id(1)); + let overview_index: i32 = semantics_find_index(tree, linux_preview_nav_id(0)); + expect(&state, components_index >= 0 && overview_index >= 0 && + tree.nodes.data[components_index].selected == 1 && + tree.nodes.data[overview_index].selected == 0, + "navigation selection truth uses stable semantic IDs"); + expect(&state, tree.nodes.data[components_index].bounds.x == 42 * unit_scale() && + tree.nodes.data[components_index].bounds.width == 176 * unit_scale(), + "semantic hit bounds match the visible Flex-aligned navigation frame"); + let session: TalkbackSession = talkback_session_make(talkback_capabilities()); + let query: TalkbackResponse = talkback_dispatch(&session, tree, + rect(0, 0, 1120 * unit_scale(), 720 * unit_scale()), + talkback_request(1, TalkbackCommand.query, + linux_preview_nav_id(1), tree.revision)); + expect(&state, query.status == TalkbackStatus.accepted && + query.resolved_role == SemanticRole.tab && query.has_bounds == 1, + "Talkback queries the live preview by stable ID without pixels"); + let click: TalkbackResponse = talkback_dispatch(&session, tree, + rect(0, 0, 1120 * unit_scale(), 720 * unit_scale()), + talkback_request(2, TalkbackCommand.click, + linux_preview_inspect_id(), tree.revision)); + expect(&state, click.status == TalkbackStatus.accepted && click.emitted_action == 1, + "Talkback emits the declared Inspect action through semantic identity"); + let chart_index: i32 = semantics_find_index(tree, + linux_preview_chart_table_id()); + let chart_value_index: i32 = semantics_find_index(tree, + linux_preview_chart_cell_id(1)); + expect(&state, chart_index >= 0 && chart_value_index >= 0 && + tree.nodes.data[chart_index].role == SemanticRole.table && + tree.nodes.data[chart_index].row_count == 8 && + tree.nodes.data[chart_index].column_count == 2 && + tree.nodes.data[chart_value_index].value.len > 0, + "the visible chart exposes axes context and an equivalent semantic table"); + let state_chip_index: i32 = semantics_find_index(tree, + linux_preview_chip_id(0)); + let layout_chip_index: i32 = semantics_find_index(tree, + linux_preview_chip_id(1)); + let render_chip_index: i32 = semantics_find_index(tree, + linux_preview_chip_id(3)); + expect(&state, state_chip_index >= 0 && layout_chip_index >= 0 && + render_chip_index >= 0 && + tree.nodes.data[state_chip_index].role == SemanticRole.tab && + tree.nodes.data[state_chip_index].selected == 1 && + tree.nodes.data[layout_chip_index].selected == 0 && + tree.nodes.data[render_chip_index].disabled == 1 && + tree.nodes.data[render_chip_index].action_mask == 0, + "the stage selector has explicit selected default and disabled states"); + let resized: SemanticsTree = linux_preview_semantics(1400, 800, 1); + let resized_index: i32 = semantics_find_index(resized, linux_preview_nav_id(1)); + expect(&state, resized_index >= 0 && + resized.nodes.data[resized_index].bounds.width > + tree.nodes.data[components_index].bounds.width && + talkback_tree_hash(resized) != talkback_tree_hash(tree), + "resize recomputes semantic geometry while retaining stable IDs"); + expect(&state, linux_preview_navigation_at_pixel(100, 230, 1120, 720) == 1 && + linux_preview_navigation_at_pixel(125, 256, 1400, 800) == 1 && + linux_preview_navigation_at_pixel(10, 230, 1120, 720) == 0 - 1, + "physical pointer routing resolves through the same adaptive navigation bounds"); + talkback_session_free(&session); semantics_tree_free(&resized); semantics_tree_free(&tree); + _zag_print("Linux preview semantics: pass="); _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/emit-agent-checklist.sh b/tools/emit-agent-checklist.sh index bc5fd87..459ab99 100755 --- a/tools/emit-agent-checklist.sh +++ b/tools/emit-agent-checklist.sh @@ -95,6 +95,7 @@ goal_to_fallback_reason = { "G3-MOTION": "advanced motion contract beyond replay is not yet implemented", "G3-REDUCED-MOTION": "reduced-motion replacement matrix and assertions are not yet implemented", "G3-ASSET-PIPELINE": "asset lifecycle, missing asset behavior, and cleanup contracts are not yet implemented", + "G5-SHOWCASE-CONFORMANCE": "the experimental preview does not yet pass token provenance, semantic symbol/color, elevation, interaction-state, typography-ramp, chart, segmented-control, and navigation-role proof", "G4-INPUT": "input routing across pointer/keyboard/touch/pen/gamepad host seams is not yet implemented", "G4-GESTURES": "gesture arbitration and handoff contracts are not yet implemented", "G4-CLI": "CLI host workflow is currently headless-only and lacks promoted-target clean-workflow verification", @@ -126,6 +127,7 @@ goal_to_fallback_reason = { "G7-PERFORMANCE": "depends on 120Hz/idle/stall/recovery evidence on reference hardware", "G7-PACKAGING": "depends on install/update/uninstall coverage on all supported platforms", "G7-ONE-POINT-ZERO": "depends on every remaining milestone and unexpired waivers", + "G1-SOURCE-FIRST": "ongoing invariant: fix reusable compiler, runtime, ABI, package, concurrency, tooling, and language-ergonomics causes in canonical Zag with an upstream regression before resuming a consumer", } @@ -196,7 +198,8 @@ lines.append("") lines.append("- Advance upstream prerequisites in `/home/micah/Desktop/Sylorlabs/zag` until no required G1 entries are `missing`/`partial`.") lines.append("- Complete RFC 0007 full-direction acceptance after full visual matrix evidence is generated.") lines.append("- Implement Linux shell/AT-SPI and capability-backed backends only after capability blockers are reduced.") -lines.append("- Resume PrismStudio migration once repository write access is available and inventory-driven UI replacement is planned.") +lines.append("- Remove PrismStudio consumer workarounds only after their reusable causes have upstream Zag regressions and fixed compiler revisions.") +lines.append("- Continue the inventory-driven PrismStudio overhaul in the canonical repository, with native tests and screenshot evidence for every promoted surface.") content = "\n".join(lines) + "\n" diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 46aa735..852922f 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -18,6 +18,8 @@ cd "$root" "$tmp/platform-capabilities-contract" "$znc" tests/unicode_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/unicode-contract" "$tmp/unicode-contract" +"$znc" tests/segmentation_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/segmentation-contract" +"$tmp/segmentation-contract" "$znc" tests/opentype_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/opentype-contract" "$tmp/opentype-contract" "$znc" tests/opentype_fuzz.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/opentype-fuzz" @@ -26,10 +28,14 @@ cd "$root" "$tmp/glyph-path-contract" "$znc" tests/glyph_run_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/glyph-run-contract" "$tmp/glyph-run-contract" +"$znc" tests/glyph_run_codec_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/glyph-run-codec-contract" +"$tmp/glyph-run-codec-contract" "$znc" tests/flex_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/flex-contract" "$tmp/flex-contract" "$znc" tests/flex_adaptive_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/flex-adaptive-contract" "$tmp/flex-adaptive-contract" +"$znc" tests/design_tokens_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/design-tokens-contract" +"$tmp/design-tokens-contract" "$znc" tests/measure_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/measure-contract" "$tmp/measure-contract" "$znc" tests/overlay_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/overlay-contract" @@ -48,6 +54,8 @@ cd "$root" "$tmp/collection-semantics-contract" "$znc" tests/talkback_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/talkback-contract" "$tmp/talkback-contract" +"$znc" tests/linux_preview_semantics_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/linux-preview-semantics-contract" +"$tmp/linux-preview-semantics-contract" "$znc" tests/render_resources_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/render-resources-contract" "$tmp/render-resources-contract" "$znc" tests/path_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/path-contract" @@ -86,4 +94,4 @@ printf 'Linux preview CPU snapshot contract: pass=2 fail=0\n' "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (backend truth, state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, collection semantics, Talkback, owned render resources, canonical paths and images, bounded PNG decode, display lists, analytic rounded geometry, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion)\n' +printf 'headless test: PASS (backend truth, state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, Unicode grapheme segmentation, positioned glyph-run transport, collection semantics, Talkback, native-preview semantics, owned render resources, canonical paths and images, bounded PNG decode, display lists, analytic rounded geometry, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion)\n' From 2ea06d3f59cb3fbdb4e04925acdd8043785052a3 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 03:41:33 -0700 Subject: [PATCH 085/103] Add retained Button and Canvas components --- README.md | 4 +- contracts/components.json | 6 +- docs/architecture/README.md | 18 + docs/architecture/canvas.md | 237 +++++++++ docs/components/button.md | 99 ++++ src/components/button.zag | 589 +++++++++++++++++++++ src/components/canvas.zag | 846 +++++++++++++++++++++++++++++++ src/design/tokens.zag | 5 +- src/semantics/semantics.zag | 16 + src/text/unicode.zag | 80 +++ tests/button_contract.zag | 267 ++++++++++ tests/canvas_contract.zag | 504 ++++++++++++++++++ tests/design_tokens_contract.zag | 2 +- tests/semantics_contract.zag | 18 + tests/unicode_contract.zag | 67 +++ tools/check-contracts.sh | 16 +- tools/test-headless.sh | 6 +- 17 files changed, 2770 insertions(+), 10 deletions(-) create mode 100644 docs/architecture/canvas.md create mode 100644 docs/components/button.md create mode 100644 src/components/button.zag create mode 100644 src/components/canvas.zag create mode 100644 tests/button_contract.zag create mode 100644 tests/canvas_contract.zag diff --git a/README.md b/README.md index ac89d98..7dd0a01 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ is normative in [DEPENDENCIES.md](DEPENDENCIES.md). | Product and architecture contract | accepted | [RFC index](docs/rfcs/README.md) | | Compiler dependency | pinned, prerequisites incomplete | [toolchain lock](contracts/toolchain.json) | | Platform shells | unavailable | [support matrix](SUPPORT.md) | -| Headless core | experimental state, reconciliation, intrinsic measurement, geometry, Flex, Grid, Overlay, scroll, virtual collections, collection semantics, Talkback, canonical paths and images, display lists, CPU raster, input, replay, and motion | [headless test](tools/test-headless.sh) | -| Components and visual language | inventory only, visual review pending | [component inventory](contracts/components.json) | +| Headless core | experimental state, reconciliation, intrinsic measurement, geometry, Flex, shared Button composition, retained Canvas drawing, Grid, Overlay, scroll, virtual collections, collection semantics, Talkback, canonical paths and images, display lists, CPU raster, input, replay, and motion | [headless test](tools/test-headless.sh) | +| Components and visual language | Button and Canvas implementing against headless contracts; direction review and all other component families remain incomplete | [component inventory](contracts/components.json) | | Flex and Zagkit Talkback | Flex foundation and in-process ID-first Talkback dispatch executing; native transport remains unavailable | [Talkback contract](docs/automation/talkback.md) | | Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | diff --git a/contracts/components.json b/contracts/components.json index 0b33c97..b3464c9 100644 --- a/contracts/components.json +++ b/contracts/components.json @@ -1,5 +1,5 @@ { - "schema_version": 1, + "schema_version": 2, "generated_for": "0.1.0-experimental.0", "status_vocabulary": ["planned", "implementing", "experimental", "conformant"], "conformance_dimensions": [ @@ -31,7 +31,7 @@ {"id":"shadow","family":"effects","name":"Shadow","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["density","contrast","color_space"]}, {"id":"light","family":"effects","name":"Light","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["contrast","color_space","reduced_transparency"]}, {"id":"divider","family":"content","name":"Divider","milestone":2,"status":"planned","semantic_roles":["separator"],"inputs":[],"adaptive":["density","contrast","direction"]}, - {"id":"button","family":"actions","name":"Button","milestone":2,"status":"planned","semantic_roles":["button"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","contrast","reduced_motion"]}, + {"id":"button","family":"actions","name":"Button","milestone":2,"status":"implementing","semantic_roles":["button"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","contrast","reduced_motion"],"source":"src/components/button.zag","evidence":"tests/button_contract.zag","documentation":"docs/components/button.md"}, {"id":"toggle","family":"actions","name":"Toggle","milestone":2,"status":"planned","semantic_roles":["switch"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","contrast","reduced_motion"]}, {"id":"checkbox","family":"actions","name":"Checkbox","milestone":2,"status":"planned","semantic_roles":["checkbox"],"inputs":["pointer","keyboard","touch"],"adaptive":["density","platform","contrast"]}, {"id":"radio","family":"actions","name":"RadioGroup","milestone":2,"status":"planned","semantic_roles":["radio","radiogroup"],"inputs":["pointer","keyboard","touch"],"adaptive":["density","platform","direction"]}, @@ -67,7 +67,7 @@ {"id":"overlay","family":"layout","name":"Overlay","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["direction","text_scale"]}, {"id":"scroll","family":"layout","name":"Scroll","milestone":2,"status":"planned","semantic_roles":["group"],"inputs":["pointer","keyboard","touch","pen","wheel","gamepad"],"adaptive":["density","platform","direction","reduced_motion"]}, {"id":"safe-area","family":"layout","name":"SafeArea","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["platform","orientation"]}, - {"id":"canvas","family":"advanced","name":"Canvas","milestone":2,"status":"planned","semantic_roles":["group"],"inputs":["pointer","keyboard","touch","pen"],"adaptive":["density","contrast","direction"]}, + {"id":"canvas","family":"advanced","name":"Canvas","milestone":2,"status":"implementing","semantic_roles":["group"],"inputs":["pointer","keyboard","touch","pen"],"adaptive":["density","contrast","direction"],"source":"src/components/canvas.zag","evidence":"tests/canvas_contract.zag","documentation":"docs/architecture/canvas.md"}, {"id":"viewport","family":"advanced","name":"Viewport","milestone":4,"status":"planned","semantic_roles":["application","group"],"inputs":["pointer","keyboard","touch","pen","wheel","gamepad","gesture"],"adaptive":["density","platform","contrast","reduced_motion"]}, {"id":"command-palette","family":"advanced","name":"CommandPalette","milestone":4,"status":"planned","semantic_roles":["dialog","searchbox","listbox"],"inputs":["pointer","keyboard","touch"],"adaptive":["density","platform","locale","direction","text_scale"]} ] diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 70f7069..afeb6bf 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -28,6 +28,8 @@ Normative decisions: Detailed experimental contracts: +- [shared Button identity, state, measurement, semantics, and Talkback](../components/button.md) +- [retained Canvas immediate drawing escape hatch](canvas.md) - [intrinsic measurement and invalidation reasons](measurement.md) - [Flex placement and adaptive spacing](flex.md) - [motion scheduler and tracks](motion.md) @@ -110,6 +112,22 @@ z-order deterministically, and keeps pointer capture and focus explicit. Accepted, missed, captured, cancelled, and invalid events share one ordered evidence stream; platform input adaptation remains open. +The first public component slice composes these trees rather than drawing a +screen-local control. [Button](../components/button.md) atomically emits one +stable ID into display, hit, and semantics builders; resolves its eight +canonical states through semantic design tokens; measures through Flex; +exposes exact caller-owned Text bounds; and executes by ID through Zagkit +Talkback. Keyboard/gamepad adapters, declarative view reconciliation, public +Text composition, high-contrast variants, and native accessibility bridges +remain incomplete, so the inventory status is `implementing`, not conformant. + +The public [Canvas](canvas.md) escape hatch similarly keeps immediate CAD and +custom drawing inside Zagkit truth: one retained key owns bounded commands and +resources, transform/clip contribution, explicit semantic policy, hit-space +conversion, cleanup, and deterministic replay identity. It is the viewport +bridge PrismStudio needs, but it does not claim GPU submission, native +accessibility, or a complete declarative Canvas view. + The first deterministic replay executor consumes an immutable ordered tape of state revisions, pointer events, monotonic clock samples, backend activation, loss, and recovery. It rejects stale or impossible transitions at their exact diff --git a/docs/architecture/canvas.md b/docs/architecture/canvas.md new file mode 100644 index 0000000..964df6e --- /dev/null +++ b/docs/architecture/canvas.md @@ -0,0 +1,237 @@ +# Canvas retained and immediate drawing contract + +Status: experimental headless component primitive + +`Canvas` is Zagkit's public immediate drawing escape hatch. It lets a retained +view such as a CAD viewport record paths, images, glyph runs, clips, transforms, +layers, and effects without inventing an application-owned rendering or input +subsystem. It does not bypass Zagkit's display-list validation, resource +ownership, semantics, hit testing, limits, or deterministic replay. + +The implementation is [`src/components/canvas.zag`](../../src/components/canvas.zag). +The focused executable proof is +[`tests/canvas_contract.zag`](../../tests/canvas_contract.zag). + +## Retained identity and immediate frames + +Every Canvas is created with one positive, stable `NodeKey`. That exact key is +used for: + +- the retained `ViewSpec` returned by `canvas_retained_spec`; +- every contributed `DisplayOp.owner`; +- the Canvas `SemanticsNode`; +- the Canvas `HitNode`; and +- namespacing local render-resource IDs. + +A Canvas value owns one immediate frame. Record commands and resources, call +`canvas_seal`, consume its immutable contributions, and finally call +`canvas_free` exactly once. Build the next frame with the same `NodeKey`. +`canvas_retained_spec` then lets reconciliation reuse the existing +`RenderNode`; a changed frame updates its fingerprint instead of replacing its +identity. + +This frame model deliberately has no mutable resource replacement API. Resource +payloads and operations cannot change after sealing. Rebuild a frame under the +same key when viewport content changes. + +## Minimal API + +```zag +@import("src/components/canvas.zag") + +let spec: CanvasSpec = canvas_spec( + node_key_scoped(4100, window_generation), + rect(0, 0, viewport_width, viewport_height), + CanvasSemanticsPolicy.named_group, + "Model viewport", +); +spec.description = "Interactive assembly editing surface"; +spec.hit_testable = 1; +spec.focusable = 1; +spec.focus_order = 4; +spec.transform.translate_x = viewport_x; +spec.transform.translate_y = viewport_y; + +let canvas: Canvas = canvas_make(spec, canvas_limits_default()); + +let background: DisplayOp = canvas_op(canvas, DisplayOpKind.fill_rect); +background.bounds = spec.bounds; +background.paint = paint_rgba16(4000, 5000, 7000, 65535); +_ = canvas_push(&canvas, background); + +if (canvas_seal(&canvas) == CanvasError.none) { + _ = canvas_contribute_display(&canvas, &frame_display_list); + _ = canvas_contribute_semantics(&canvas, &semantics_tree); + _ = canvas_contribute_hit(&canvas, &hit_tree); +} + +_ = canvas_free(&canvas); +``` + +Production code must inspect every returned `CanvasError`. `last_error` plus +`display_error`, `resource_error`, `semantics_error`, `hit_error`, +`error_index`, and `error_resource_id` preserve the exact failing boundary. + +## Bounds, clip, and transform + +Geometry uses Zagkit's signed 26.6 fixed-point logical units. `bounds` and an +optional `clip` are Canvas-local. A declared clip must be positive and wholly +inside bounds; no implicit intersection hides malformed geometry. Even without +an explicit clip, Canvas content is clipped to bounds. + +`transform` maps local coordinates into the parent/window coordinate space. +Display contribution emits one explicit sequence: + +1. `save`; +2. `concat_transform`; +3. `clip_rect` using the effective local clip; +4. the sealed immediate operations; and +5. `restore`. + +The transformed clip's axis-aligned bounds become the semantic bounds. Singular, +unsafe, zero-area, or out-of-range interactive transforms fail during Canvas +construction. `canvas_local_from_world` uses the same affine inverse and clip +rules as `HitTree`, so platform input and declared Canvas pixel fallback cannot +disagree about local coordinates. + +## Semantics policy + +Every Canvas must select exactly one policy: + +| Policy | Semantic output | Requirements | +|---|---|---| +| `named_image` | visible `SemanticRole.image` | nonempty accessible name | +| `named_group` | visible `SemanticRole.group` | nonempty accessible name | +| `decorative` | hidden, unnamed `SemanticRole.group` | no name, description, hit target, or focus | + +An interactive CAD viewport should normally be a `named_group`. A read-only +rendered preview may be a `named_image`. Decoration is explicit rather than +implemented by omitting semantic truth. A decorative Canvas cannot silently +become an agent-controlled pixel target. + +Setting `focusable` requires `hit_testable` and a positive `focus_order`; Canvas +then emits the same focus capability to semantics and hit testing. More detailed +viewport controls, selections, handles, and actions should be semantic child +nodes with their own stable IDs rather than one opaque Canvas action. +When the Canvas is disabled, it remains discoverable with disabled semantic +state but exposes no focus action and cannot win hit testing. + +Zagkit Talkback should target those IDs first. Scale-aware pixel fallback is +appropriate only for direct coordinates inside a declared Canvas and must be +reported as pixel fallback, never as an ID action. + +## Resource ownership + +Use `CanvasResourceSpec` and `canvas_add_resource`. `local_id` is positive and +stable within the Canvas. `canvas_resource_id(canvas.key, local_id)` derives the +positive display-list resource ID; `canvas_resource_op` records that identity +on a resource-backed operation. + +Canvas copies caller payload bytes immediately. Display contribution copies +the sealed Canvas resources again into the destination `DisplayList`, so the +destination stays valid after `canvas_free`. The destination rejects a scoped +ID collision before any contribution operation or resource is added. Resource +kind, format, dimensions, color space, payload size, canonical path/image +payload, and referenced-resource validation remain the existing +`RenderResourceStore` and `DisplayList` contracts. + +This copy-owned experimental contract favors deterministic lifetime truth over +zero-copy upload. Future cache or transport work may optimize storage without +weakening ownership, identity, or replay. + +## Display contribution and failure atomicity + +`canvas_contribute_display` requires a sealed, verified Canvas and an unsealed +destination. Before mutation it checks: + +- destination seal and resource-store state; +- operation count; +- resource count, total bytes, and per-payload bytes; and +- every resource-ID collision. + +All contract-defined destination failures therefore leave the destination +unchanged. A successful contribution remains unsealed so the parent can append +other retained nodes before sealing the complete frame. + +Semantics and hit contributions use the transactional add behavior of their +respective trees. Missing parents, duplicate IDs, duplicate focus order, or +invalid tree state remain visible without a partial node. + +## Deterministic replay + +`canvas_replay_hash` verifies the sealed display list and then returns an +identity covering: + +- `NodeKey`, parents, bounds, clip, transform, and z-order; +- semantic policy, owned name and description, focus, hit, and enabled state; +- allocation and destination-operation limits; and +- the complete sealed display-list resource and operation identity. + +Identical Canvas frames produce the same hash. A key generation, semantic, +geometry, paint, command, resource, or limit change changes it. Raw mutation of +sealed operations or resources makes verification fail and returns no replay +hash. + +## Fail-closed limits + +`canvas_limits_default` currently resolves to: + +| Limit | Default | Hard ceiling | +|---|---:|---:| +| Immediate operations | 262,144 | 1,000,000 | +| Owned resources | 4,096 | 65,536 | +| Total owned resource bytes | 256 MiB | 512 MiB | +| One resource payload | 32 MiB | 64 MiB | +| Parent operations after contribution | 1,000,000 | 1,000,000 | +| Accessible name bytes | 4,096 | 4,096 | +| Accessible description bytes | 16,384 | 16,384 | + +Nonpositive resource IDs, malformed UTF-8 or embedded-NUL semantic text, +invalid flag states, +foreign operation owners, malformed geometry, singular transforms, unbalanced +display state, unsupported resource payloads, and writes after sealing all fail +before the rejected mutation. Borrowed semantic text and limits are validated +before any proportional copy is allocated. Existing stricter path, image, +CPU-raster, and codec limits still apply. + +## Current boundaries + +This contract is a headless component primitive, not a platform or renderer +completion claim: + +- it does not make currently unsupported CPU or GPU display operations work; +- it does not provide a native Talkback transport, IME, AT-SPI, or window input; +- it does not grant physical-GPU execution or weaken PrismStudio's explicit GPU + certification boundary; +- it does not infer semantic children from pixels; +- it is not thread-safe and does not accept arbitrary callbacks; and +- it does not make unbounded frame allocation acceptable. + +The CPU oracle and public backend capability record remain authoritative. + +## PrismStudio integration order + +The first PrismStudio bridge should keep the existing CAD domain and viewport +algorithms while replacing application-owned UI seams: + +1. Assign the viewport a stable app/window-scoped `NodeKey` and use + `named_group` with an explicit Talkback ID mapping. +2. Feed the viewport's canonical CPU output through Canvas display operations + and owned path/image resources. Do not borrow framebuffer pointers or add a + sibling-path/package workaround. +3. Contribute Canvas display, semantics, and hit nodes to the same Zagkit frame. +4. Route pointer coordinates through `canvas_local_from_world`; keep ordinary + tools, buttons, selections, and commands ID-addressable, with pixel fallback + limited to declared viewport coordinates. +5. Compare Canvas CPU output to PrismStudio's current CPU oracle before changing + transport or enabling any separately authorized GPU path. +6. Preserve the current GPU safety policy and run focused Canvas, PrismStudio + CPU/X11, semantics, Talkback, screenshot, and cleanup gates before removing + the legacy viewport host. + +The focused contract compiles with Zag strict analysis and covers retained +reuse, replay mutation detection, transformed display/semantic/hit agreement, +all three semantic policies, resource lifetime independence, transactional +destination failures, malformed construction, bounded allocation, and repeated +cleanup. It is correctness evidence, not native accessibility, visual fidelity, +120 Hz, or release certification. diff --git a/docs/components/button.md b/docs/components/button.md new file mode 100644 index 0000000..2f113ef --- /dev/null +++ b/docs/components/button.md @@ -0,0 +1,99 @@ +# Button + +`Button` is a retained interaction contract, not a drawing helper. One stable +`NodeKey` owns its display operations, hit target, focusability, semantic node, +and Zagkit Talkback target. A successful build therefore cannot produce a +clickable shape with missing semantics, or accessible metadata with no hit +geometry. + +The implementation is experimental while the Zagkit component API is still +pre-1.0. Its invariants are release requirements. + +## Authoring contract + +Create a `ButtonSpec` with `button_spec(id, parent, bounds, label)`, then set its +variant, density, description, focus order, z-order, and `ButtonInteraction`. +Call `button_emit` with the frame's mutable `DisplayList`, `HitTree`, and +`SemanticsTree` builders. On success, the returned `ButtonArtifact` records: + +- the shared stable ID and exact bounds; +- caller-owned text content bounds resolved from Flex spacing; +- every semantic color and elevation token selected for the state; +- effective enabled and content visibility truth; +- display operation, hit-node, and semantic-node locations; and +- a deterministic evidence hash over identity, geometry, state, and tokens. + +Emission is atomic. A duplicate ID, missing parent, focus-order collision, +sealed display list, or invalid spec restores every builder to its checkpoint +and returns the concrete subsystem error. +Labels are required, strict UTF-8, NUL-free, and bounded to 4,096 bytes; +descriptions follow the same text rules and are bounded to 16,384 bytes. +Validation occurs before display or tree mutation. + +## Text ownership + +The component draws its material chrome and loading indicator. It deliberately +does not approximate label text. The caller renders a real Zagkit `Text` child +inside `artifact.content_bounds`, using `artifact.style.text_token`. The +button's label is copied into its semantic node during emission, so assistive +technology and Talkback never depend on whether glyphs happen to be visible. + +This boundary will be composed by the public declarative `Button` view once the +shared `Text` component and reconciliation layer are connected. Placeholder +bars and shell-specific text are not a supported Button implementation. + +## State model + +`ButtonInteraction` uses independent flags because selected+focused and +error+focused are valid combinations. `button_canonical_state` provides the +eight showcase fixtures: rest, hover, focus, pressed, selected, disabled, +loading, and error. Styling resolves with documented precedence: + +1. variant establishes the base material; +2. hover, selection, and press change interactive emphasis; +3. error changes the semantic error edge; +4. loading makes actions unavailable and substitutes a progress mark; and +5. disabled overrides material, text, focus, hit, and action availability. + +Focus is a separate visible ring and never relies on fill color. Selection is +also exposed through `SemanticsNode.selected`; loading and error expose text +values. Color is therefore supplementary state evidence. + +`button_reduce` accepts pointer, focus, selection, availability, loading, and +error events. It emits `activate` only for an enabled press followed by an +inside release. It never mutates application state: state still flows down and +the emitted action flows up. + +## Variants and tokens + +The current variants are `primary`, `secondary`, `quiet`, and `destructive`. +They resolve only through semantic tokens, including `color.accent`, +`color.surface.interactive`, `color.status.error`, `color.focus`, named border +roles, named text roles, semantic radii, and elevation tiers. Similar-looking +literal colors are not component API. + +## Measurement and placement + +`button_measure` accepts measured label size, optional-leading-visual truth, +constraints, and `FlexDensity`. Horizontal/vertical padding and visual gap come +from public Flex spacing tokens. Minimum targets are 32 logical pixels in +compact density, 40 in standard density, and 48 in touch density. The result +reports constraint clipping rather than silently changing content metrics. + +The visual bounds, hit bounds, semantic bounds, and Talkback bounds are exact +matches. Focus and shadow may paint outside those bounds but never enlarge the +action target invisibly. + +## Accessibility and automation + +Enabled buttons expose `activate` and `focus`; disabled and loading buttons +remain discoverable but expose neither action. Selection, disabled state, +loading/error value, description, focus order, and exact bounds share the +parallel semantics tree. Zagkit Talkback clicks the same ID and semantic action +that platform accessibility adapters consume. Pixel targeting is unnecessary +for Button. + +The executable contract in `tests/button_contract.zag` covers measurement, +state reduction, all eight showcase states, token mapping, immutable display +verification, hit identity, semantic state, Talkback activation, fail-closed +disabled behavior, and atomic rollback. diff --git a/src/components/button.zag b/src/components/button.zag new file mode 100644 index 0000000..1ac2bd4 --- /dev/null +++ b/src/components/button.zag @@ -0,0 +1,589 @@ +@import("../core/geometry.zag") +@import("../core/view_contract.zag") +@import("../design/tokens.zag") +@import("../input/hit_test.zag") +@import("../layout/adaptive.zag") +@import("../render/display_list.zag") +@import("../semantics/semantics.zag") + +// Button is one retained component contract. Its stable NodeKey and geometry +// are shared by rendering, hit testing, semantics, focus, and Talkback. +// Text remains caller-owned content: this module emits chrome and returns the +// exact content bounds and semantic text token for a Text child to render. + +enum ButtonVariant { primary, secondary, quiet, destructive } + +enum ButtonCanonicalState { + rest, + hover, + focus, + pressed, + selected, + disabled, + loading, + error, +} + +enum ButtonEvent { + pointer_enter, + pointer_leave, + pointer_down, + pointer_up_inside, + pointer_cancel, + focus_gained, + focus_lost, + select, + deselect, + enable, + disable, + begin_loading, + finish_loading, + mark_error, + clear_error, +} + +enum ButtonMeasureError { none, invalid_content, invalid_constraints } +enum ButtonBuildError { none, invalid_spec, display_error, hit_error, semantics_error } + +struct ButtonInteraction { + hovered: i32, + focused: i32, + pressed: i32, + selected: i32, + enabled: i32, + loading: i32, + error: i32, +} + +struct ButtonTransition { + interaction: ButtonInteraction, + activate: i32, + state_changed: i32, +} + +struct ButtonMeasureResult { + size: Size, + minimum_target: i64, + horizontal_padding: i64, + vertical_padding: i64, + visual_extent: i64, + visual_gap: i64, + clipped: i32, + error: ButtonMeasureError, +} + +struct ButtonResolvedStyle { + fill_token: SemanticColorToken, + border_token: SemanticColorToken, + text_token: SemanticColorToken, + focus_token: SemanticColorToken, + fill_alpha: i64, + border_alpha: i64, + radius: i64, + elevation: SemanticElevationToken, +} + +struct ButtonSpec { + id: NodeKey, + parent: NodeKey, + bounds: Rect, + label: []u8, + description: []u8, + focus_order: i64, + z_order: i64, + variant: ButtonVariant, + density: FlexDensity, + interaction: ButtonInteraction, +} + +struct ButtonArtifact { + id: NodeKey, + bounds: Rect, + content_bounds: Rect, + interaction: ButtonInteraction, + style: ButtonResolvedStyle, + effectively_enabled: i32, + content_visible: i32, + display_op_start: i32, + display_op_end: i32, + hit_node_index: i32, + semantics_node_index: i32, + evidence_hash: i64, +} + +struct ButtonBuildResult { + artifact: ButtonArtifact, + error: ButtonBuildError, + display_error: DisplayError, + hit_error: HitTreeError, + semantics_error: SemanticsError, +} + +struct ButtonBuildCheckpoint { + display_length: i32, + display_revision: i64, + display_hash: i64, + display_last_error: DisplayError, + display_error_index: i32, + hit_length: i32, + hit_revision: i64, + hit_last_error: HitTreeError, + hit_error_node: NodeKey, + semantics: SemanticsCheckpoint, +} + +fn button_interaction() ButtonInteraction { + return ButtonInteraction{ + .hovered = 0, .focused = 0, .pressed = 0, .selected = 0, + .enabled = 1, .loading = 0, .error = 0, + }; +} + +fn button_label_byte_limit() i32 { return 4096; } +fn button_description_byte_limit() i32 { return 16384; } + +fn button_canonical_state(state: ButtonCanonicalState) ButtonInteraction { + let value: ButtonInteraction = button_interaction(); + if (state == ButtonCanonicalState.hover) { value.hovered = 1; } + if (state == ButtonCanonicalState.focus) { value.focused = 1; } + if (state == ButtonCanonicalState.pressed) { value.pressed = 1; } + if (state == ButtonCanonicalState.selected) { value.selected = 1; } + if (state == ButtonCanonicalState.disabled) { value.enabled = 0; } + if (state == ButtonCanonicalState.loading) { value.loading = 1; } + if (state == ButtonCanonicalState.error) { value.error = 1; } + return value; +} + +fn button_interaction_valid(value: ButtonInteraction) i32 { + if ((value.hovered != 0 && value.hovered != 1) || + (value.focused != 0 && value.focused != 1) || + (value.pressed != 0 && value.pressed != 1) || + (value.selected != 0 && value.selected != 1) || + (value.enabled != 0 && value.enabled != 1) || + (value.loading != 0 && value.loading != 1) || + (value.error != 0 && value.error != 1)) { return 0; } + if (value.enabled == 0 && value.pressed != 0) { return 0; } + if (value.loading != 0 && value.pressed != 0) { return 0; } + return 1; +} + +fn button_effectively_enabled(value: ButtonInteraction) i32 { + return (value.enabled != 0 && value.loading == 0) as i32; +} + +fn button_interaction_equal(left: ButtonInteraction, right: ButtonInteraction) i32 { + return (left.hovered == right.hovered && left.focused == right.focused && + left.pressed == right.pressed && left.selected == right.selected && + left.enabled == right.enabled && left.loading == right.loading && + left.error == right.error) as i32; +} + +fn button_reduce(current: ButtonInteraction, event: ButtonEvent) ButtonTransition { + let next: ButtonInteraction = current; + let activate: i32 = 0; + let available: i32 = button_effectively_enabled(current); + switch (event) { + .pointer_enter => { if (available != 0) { next.hovered = 1; } } + .pointer_leave => { next.hovered = 0; next.pressed = 0; } + .pointer_down => { if (available != 0) { next.pressed = 1; } } + .pointer_up_inside => { + if (available != 0 && current.pressed != 0) { activate = 1; } + next.pressed = 0; + } + .pointer_cancel => { next.pressed = 0; } + .focus_gained => { if (available != 0) { next.focused = 1; } } + .focus_lost => { next.focused = 0; next.pressed = 0; } + .select => { next.selected = 1; } + .deselect => { next.selected = 0; } + .enable => { next.enabled = 1; } + .disable => { + next.enabled = 0; next.hovered = 0; next.focused = 0; + next.pressed = 0; + } + .begin_loading => { next.loading = 1; next.pressed = 0; } + .finish_loading => { next.loading = 0; } + .mark_error => { next.error = 1; next.loading = 0; next.pressed = 0; } + .clear_error => { next.error = 0; } + } + return ButtonTransition{ + .interaction = next, + .activate = activate, + .state_changed = (button_interaction_equal(current, next) == 0) as i32, + }; +} + +fn button_minimum_target(density: FlexDensity) i64 { + return switch (density) { + .compact => 32 * unit_scale(), + .standard => 40 * unit_scale(), + .touch => 48 * unit_scale(), + }; +} + +fn button_measure(label_size: Size, has_leading_visual: i32, + constraints: Constraints, density: FlexDensity) ButtonMeasureResult { + let result: ButtonMeasureResult = ButtonMeasureResult{ + .size = size(0, 0), .minimum_target = button_minimum_target(density), + .horizontal_padding = flex_spacing(FlexSpacingToken.large, density), + .vertical_padding = flex_spacing(FlexSpacingToken.small, density), + .visual_extent = 18 * unit_scale(), + .visual_gap = flex_spacing(FlexSpacingToken.small, density), + .clipped = 0, .error = ButtonMeasureError.none, + }; + if (label_size.width < 0 || label_size.height < 0 || + label_size.width > unbounded_extent() / 4 || + label_size.height > unbounded_extent() / 4 || + (has_leading_visual != 0 && has_leading_visual != 1)) { + result.error = ButtonMeasureError.invalid_content; + return result; + } + let normalized: Constraints = normalize_constraints(constraints); + if (normalized.min_width > unbounded_extent() || + normalized.max_width > unbounded_extent() || + normalized.min_height > unbounded_extent() || + normalized.max_height > unbounded_extent()) { + result.error = ButtonMeasureError.invalid_constraints; + return result; + } + let content_width: i64 = label_size.width; + if (has_leading_visual != 0) { + content_width = content_width + result.visual_extent; + if (label_size.width > 0) { content_width = content_width + result.visual_gap; } + } + let desired_width: i64 = content_width + result.horizontal_padding * 2; + let desired_height: i64 = label_size.height + result.vertical_padding * 2; + if (desired_width < result.minimum_target) { desired_width = result.minimum_target; } + if (desired_height < result.minimum_target) { desired_height = result.minimum_target; } + result.size = constrain_size(size(desired_width, desired_height), normalized); + result.clipped = (result.size.width < content_width + result.visual_gap * 2 || + result.size.height < label_size.height + result.visual_gap * 2) as i32; + return result; +} + +fn button_spec(id: NodeKey, parent: NodeKey, bounds: Rect, + label: []u8) ButtonSpec { + return ButtonSpec{ + .id = id, .parent = parent, .bounds = bounds, + .label = label, .description = "", .focus_order = 0, .z_order = 0, + .variant = ButtonVariant.secondary, .density = FlexDensity.standard, + .interaction = button_interaction(), + }; +} + +fn button_style(spec: ButtonSpec) ButtonResolvedStyle { + let style: ButtonResolvedStyle = ButtonResolvedStyle{ + .fill_token = SemanticColorToken.surface_interactive, + .border_token = SemanticColorToken.border_subtle, + .text_token = SemanticColorToken.text_primary, + .focus_token = SemanticColorToken.focus, + .fill_alpha = 56000, + .border_alpha = 18000, + .radius = semantic_radius(SemanticRadiusToken.control), + .elevation = SemanticElevationToken.panel, + }; + if (spec.variant == ButtonVariant.primary) { + style.fill_token = SemanticColorToken.accent; + style.border_token = SemanticColorToken.edge_highlight; + style.fill_alpha = 62000; + style.border_alpha = 19000; + style.elevation = SemanticElevationToken.raised; + } + if (spec.variant == ButtonVariant.quiet) { + style.fill_token = SemanticColorToken.surface_base; + style.fill_alpha = 0; + style.border_alpha = 0; + style.elevation = SemanticElevationToken.base; + } + if (spec.variant == ButtonVariant.destructive) { + style.fill_token = SemanticColorToken.status_error; + style.border_token = SemanticColorToken.status_error; + style.fill_alpha = 50000; + style.border_alpha = 65535; + style.elevation = SemanticElevationToken.raised; + } + if (spec.interaction.hovered != 0) { + if (spec.variant == ButtonVariant.primary) { + style.fill_token = SemanticColorToken.accent_emphasis; + } else if (spec.variant != ButtonVariant.destructive) { + style.fill_token = SemanticColorToken.surface_selected; + } + style.fill_alpha = 61000; + style.border_alpha = 24000; + } + if (spec.interaction.selected != 0) { + style.fill_token = SemanticColorToken.surface_selected; + style.border_token = SemanticColorToken.accent; + style.fill_alpha = 61000; + style.border_alpha = 43000; + } + if (spec.interaction.pressed != 0) { + style.fill_token = SemanticColorToken.accent_emphasis; + style.fill_alpha = 65535; + style.elevation = SemanticElevationToken.base; + } + if (spec.interaction.error != 0) { + style.border_token = SemanticColorToken.status_error; + style.border_alpha = 65535; + } + if (spec.interaction.loading != 0) { + style.fill_token = SemanticColorToken.surface_interactive; + style.text_token = SemanticColorToken.text_secondary; + style.fill_alpha = 50000; + style.elevation = SemanticElevationToken.base; + } + if (spec.interaction.enabled == 0) { + style.fill_token = SemanticColorToken.surface_inset; + style.border_token = SemanticColorToken.border_subtle; + style.text_token = SemanticColorToken.text_disabled; + style.fill_alpha = 36000; + style.border_alpha = 9000; + style.elevation = SemanticElevationToken.base; + } + return style; +} + +fn button_content_bounds(spec: ButtonSpec) Rect { + let horizontal: i64 = flex_spacing(FlexSpacingToken.large, spec.density); + let vertical: i64 = flex_spacing(FlexSpacingToken.small, spec.density); + let h: i64 = horizontal; + let v: i64 = vertical; + if (h * 2 >= spec.bounds.width) { h = (spec.bounds.width - unit_scale()) / 2; } + if (v * 2 >= spec.bounds.height) { v = (spec.bounds.height - unit_scale()) / 2; } + if (h < 0) { h = 0; } + if (v < 0) { v = 0; } + return rect(spec.bounds.x + h, spec.bounds.y + v, + spec.bounds.width - h * 2, spec.bounds.height - v * 2); +} + +fn button_spec_valid(spec: ButtonSpec) i32 { + if (node_key_equal(spec.id, hit_root_key()) != 0 || + node_key_equal(spec.id, semantic_none_key()) != 0 || + spec.label.len <= 0 || spec.label.len > button_label_byte_limit() || + spec.description.len > button_description_byte_limit() || + semantic_text_input_valid(spec.label) == 0 || + semantic_text_input_valid(spec.description) == 0 || + spec.focus_order < 0 || + spec.bounds.width < 16 * unit_scale() || + spec.bounds.height < 16 * unit_scale() || + hit_geometry_valid(spec.bounds) == 0 || + button_interaction_valid(spec.interaction) == 0) { return 0; } + return 1; +} + +fn button_state_value(spec: ButtonSpec) []u8 { + if (spec.interaction.loading != 0) { return "Loading"; } + if (spec.interaction.error != 0) { return "Error"; } + if (spec.interaction.selected != 0) { return "Selected"; } + return ""; +} + +fn button_hash_mix(hash: i64, value: i64) i64 { + return (hash * 1099511628211) ^ value; +} + +fn button_artifact_hash(artifact: ButtonArtifact) i64 { + let hash: i64 = button_hash_mix(17, artifact.id.value); + hash = button_hash_mix(hash, artifact.id.generation); + hash = button_hash_mix(hash, artifact.bounds.x); + hash = button_hash_mix(hash, artifact.bounds.y); + hash = button_hash_mix(hash, artifact.bounds.width); + hash = button_hash_mix(hash, artifact.bounds.height); + hash = button_hash_mix(hash, artifact.content_bounds.x); + hash = button_hash_mix(hash, artifact.content_bounds.y); + hash = button_hash_mix(hash, artifact.content_bounds.width); + hash = button_hash_mix(hash, artifact.content_bounds.height); + hash = button_hash_mix(hash, artifact.interaction.hovered as i64); + hash = button_hash_mix(hash, artifact.interaction.focused as i64); + hash = button_hash_mix(hash, artifact.interaction.pressed as i64); + hash = button_hash_mix(hash, artifact.interaction.selected as i64); + hash = button_hash_mix(hash, artifact.interaction.enabled as i64); + hash = button_hash_mix(hash, artifact.interaction.loading as i64); + hash = button_hash_mix(hash, artifact.interaction.error as i64); + hash = button_hash_mix(hash, artifact.style.fill_token as i64); + hash = button_hash_mix(hash, artifact.style.border_token as i64); + hash = button_hash_mix(hash, artifact.style.text_token as i64); + hash = button_hash_mix(hash, artifact.style.focus_token as i64); + hash = button_hash_mix(hash, artifact.style.fill_alpha); + hash = button_hash_mix(hash, artifact.style.border_alpha); + hash = button_hash_mix(hash, artifact.style.radius); + hash = button_hash_mix(hash, artifact.style.elevation as i64); + hash = button_hash_mix(hash, artifact.effectively_enabled as i64); + return button_hash_mix(hash, artifact.content_visible as i64); +} + +fn button_empty_artifact(spec: ButtonSpec) ButtonArtifact { + return ButtonArtifact{ + .id = spec.id, .bounds = spec.bounds, + .content_bounds = rect(0, 0, 0, 0), + .interaction = spec.interaction, .style = button_style(spec), + .effectively_enabled = 0, .content_visible = 0, + .display_op_start = 0 - 1, .display_op_end = 0 - 1, + .hit_node_index = 0 - 1, .semantics_node_index = 0 - 1, + .evidence_hash = 0, + }; +} + +fn button_build_result(spec: ButtonSpec, error: ButtonBuildError) ButtonBuildResult { + return ButtonBuildResult{ + .artifact = button_empty_artifact(spec), .error = error, + .display_error = DisplayError.none, .hit_error = HitTreeError.none, + .semantics_error = SemanticsError.none, + }; +} + +fn button_checkpoint(display: DisplayList, hits: HitTree, + semantics: SemanticsTree) ButtonBuildCheckpoint { + return ButtonBuildCheckpoint{ + .display_length = display.ops.len, .display_revision = display.revision, + .display_hash = display.content_hash, + .display_last_error = display.last_error, + .display_error_index = display.error_index, + .hit_length = hits.nodes.len, .hit_revision = hits.revision, + .hit_last_error = hits.last_error, .hit_error_node = hits.error_node, + .semantics = semantics_tree_checkpoint(semantics), + }; +} + +fn button_rollback(display: *DisplayList, hits: *HitTree, + semantics: *SemanticsTree, checkpoint: ButtonBuildCheckpoint) void { + display.*.ops.len = checkpoint.display_length; + display.*.revision = checkpoint.display_revision; + display.*.content_hash = checkpoint.display_hash; + display.*.last_error = checkpoint.display_last_error; + display.*.error_index = checkpoint.display_error_index; + hits.*.nodes.len = checkpoint.hit_length; + hits.*.revision = checkpoint.hit_revision; + hits.*.last_error = checkpoint.hit_last_error; + hits.*.error_node = checkpoint.hit_error_node; + _ = semantics_tree_rollback(semantics, checkpoint.semantics); +} + +fn button_push_round(display: *DisplayList, id: NodeKey, bounds: Rect, + radius: i64, paint: Paint) DisplayError { + let op: DisplayOp = display_op(DisplayOpKind.fill_rounded_rect, id); + op.bounds = bounds; + op.parameter = radius; + op.paint = paint; + return display_list_push(display, op); +} + +fn button_emit(spec: ButtonSpec, display: *DisplayList, hits: *HitTree, + semantics: *SemanticsTree) ButtonBuildResult { + let result: ButtonBuildResult = button_build_result(spec, ButtonBuildError.none); + if (button_spec_valid(spec) == 0 || display.*.sealed != 0) { + result.error = ButtonBuildError.invalid_spec; + if (display.*.sealed != 0) { + result.error = ButtonBuildError.display_error; + result.display_error = DisplayError.sealed; + } + return result; + } + let checkpoint: ButtonBuildCheckpoint = button_checkpoint( + display.*, hits.*, semantics.*); + let style: ButtonResolvedStyle = button_style(spec); + let elevation: ResolvedElevationToken = semantic_elevation(style.elevation); + let enabled: i32 = button_effectively_enabled(spec.interaction); + let op_start: i32 = display.*.ops.len; + + let display_error: DisplayError = DisplayError.none; + if (spec.interaction.focused != 0 && enabled != 0) { + let focus_extent: i64 = 3 * unit_scale(); + display_error = button_push_round(display, spec.id, + rect(spec.bounds.x - focus_extent, spec.bounds.y - focus_extent, + spec.bounds.width + focus_extent * 2, + spec.bounds.height + focus_extent * 2), + style.radius + focus_extent, + semantic_color_with_alpha(style.focus_token, 39000)); + } + if (display_error == DisplayError.none && elevation.shadow_alpha > 0 && + spec.interaction.pressed == 0 && enabled != 0) { + let spread: i64 = elevation.shadow_spread; + display_error = button_push_round(display, spec.id, + rect(spec.bounds.x - spread, + spec.bounds.y + elevation.shadow_offset_y - spread, + spec.bounds.width + spread * 2, + spec.bounds.height + spread * 2), + style.radius + spread, + semantic_color_with_alpha(SemanticColorToken.shadow, + elevation.shadow_alpha)); + } + if (display_error == DisplayError.none) { + display_error = button_push_round(display, spec.id, spec.bounds, + style.radius, semantic_color_with_alpha(style.border_token, + style.border_alpha)); + } + if (display_error == DisplayError.none) { + let edge: i64 = unit_scale(); + display_error = button_push_round(display, spec.id, + rect(spec.bounds.x + edge, spec.bounds.y + edge, + spec.bounds.width - edge * 2, + spec.bounds.height - edge * 2), + style.radius - edge, + semantic_color_with_alpha(style.fill_token, style.fill_alpha)); + } + if (display_error == DisplayError.none && spec.interaction.loading != 0) { + let indicator_width: i64 = 20 * unit_scale(); + let indicator_height: i64 = 4 * unit_scale(); + display_error = button_push_round(display, spec.id, + rect(spec.bounds.x + (spec.bounds.width - indicator_width) / 2, + spec.bounds.y + (spec.bounds.height - indicator_height) / 2, + indicator_width, indicator_height), + indicator_height / 2, + semantic_color_with_alpha(SemanticColorToken.accent, 52000)); + } + if (display_error != DisplayError.none) { + button_rollback(display, hits, semantics, checkpoint); + result.error = ButtonBuildError.display_error; + result.display_error = display_error; + return result; + } + + let hit: HitNode = hit_node(spec.id, spec.parent, spec.bounds); + hit.clip = spec.bounds; + hit.has_clip = 1; + hit.z_order = spec.z_order; + hit.enabled = enabled; + hit.focusable = enabled; + let hit_error: HitTreeError = hit_tree_add(hits, hit); + if (hit_error != HitTreeError.none) { + button_rollback(display, hits, semantics, checkpoint); + result.error = ButtonBuildError.hit_error; + result.hit_error = hit_error; + return result; + } + + let semantic: SemanticsSpec = semantics_spec( + spec.id, spec.parent, SemanticRole.button, spec.label); + semantic.description = spec.description; + semantic.value = button_state_value(spec); + semantic.focus_order = spec.focus_order; + semantic.disabled = (enabled == 0) as i32; + semantic.selected = spec.interaction.selected; + semantic.has_bounds = 1; + semantic.bounds = spec.bounds; + if (enabled != 0) { + semantic.action_mask = semantic_actions_add( + semantic_actions(SemanticAction.activate), SemanticAction.focus); + } + let semantic_error: SemanticsError = semantics_add(semantics, semantic); + if (semantic_error != SemanticsError.none) { + button_rollback(display, hits, semantics, checkpoint); + result.error = ButtonBuildError.semantics_error; + result.semantics_error = semantic_error; + return result; + } + + result.artifact = ButtonArtifact{ + .id = spec.id, .bounds = spec.bounds, + .content_bounds = button_content_bounds(spec), + .interaction = spec.interaction, .style = style, + .effectively_enabled = enabled, + .content_visible = (spec.interaction.loading == 0) as i32, + .display_op_start = op_start, .display_op_end = display.*.ops.len, + .hit_node_index = hits.*.nodes.len - 1, + .semantics_node_index = semantics.*.nodes.len - 1, + .evidence_hash = 0, + }; + result.artifact.evidence_hash = button_artifact_hash(result.artifact); + return result; +} diff --git a/src/components/canvas.zag b/src/components/canvas.zag new file mode 100644 index 0000000..a0eb228 --- /dev/null +++ b/src/components/canvas.zag @@ -0,0 +1,846 @@ +@import("../core/reconcile.zag") +@import("../render/display_list.zag") +@import("../semantics/semantics.zag") +@import("../input/hit_test.zag") + +// Canvas is the public immediate drawing escape hatch hosted by one retained +// NodeKey. A Canvas owns its command and resource bytes until canvas_free. + +enum CanvasSemanticsPolicy { + named_image, + named_group, + decorative, +} + +enum CanvasError { + none, + invalid_key, + invalid_limits, + invalid_bounds, + invalid_clip, + invalid_transform, + invalid_semantics, + invalid_state, + sealed, + not_sealed, + released, + too_many_operations, + invalid_owner, + invalid_resource_id, + resource_failure, + display_failure, + content_mutated, + destination_sealed, + destination_limit, + resource_collision, + semantics_failure, + hit_failure, + not_interactive, +} + +struct CanvasLimits { + max_operations: i32, + max_resources: i32, + max_total_resource_bytes: i64, + max_resource_bytes: i64, + max_destination_operations: i32, +} + +struct CanvasSpec { + key: NodeKey, + semantic_parent: NodeKey, + hit_parent: NodeKey, + bounds: Rect, + clip: Rect, + has_clip: i32, + transform: Transform, + semantics_policy: CanvasSemanticsPolicy, + name: []u8, + description: []u8, + hit_testable: i32, + focusable: i32, + focus_order: i64, + enabled: i32, + z_order: i64, +} + +struct CanvasResourceSpec { + local_id: i64, + kind: RenderResourceKind, + payload: []u8, + format_tag: i64, + width: i64, + height: i64, + color_space: RenderColorSpace, +} + +struct Canvas { + key: NodeKey, + semantic_parent: NodeKey, + hit_parent: NodeKey, + bounds: Rect, + clip: Rect, + has_clip: i32, + transform: Transform, + semantics_policy: CanvasSemanticsPolicy, + name: ArrayList[u8], + description: ArrayList[u8], + hit_testable: i32, + focusable: i32, + focus_order: i64, + enabled: i32, + z_order: i64, + limits: CanvasLimits, + display: DisplayList, + sealed: i32, + released: i32, + revision: i64, + content_hash: i64, + last_error: CanvasError, + display_error: DisplayError, + resource_error: RenderResourceError, + semantics_error: SemanticsError, + hit_error: HitTreeError, + error_index: i32, + error_resource_id: i64, +} + +struct CanvasPoint { + x: i64, + y: i64, +} + +struct CanvasLocalResult { + found: i32, + x: i64, + y: i64, + error: CanvasError, +} + +struct CanvasRetainedResult { + spec: ViewSpec, + error: CanvasError, +} + +fn canvas_render_kind() i64 { return 1128353366; } +fn canvas_hard_operation_limit() i32 { return 1000000; } +fn canvas_hard_name_limit() i32 { return 4096; } +fn canvas_hard_description_limit() i32 { return 16384; } +fn canvas_local_resource_id_limit() i64 { return 2147483647; } + +fn canvas_limits_default() CanvasLimits { + return CanvasLimits{ + .max_operations = 262144, + .max_resources = 4096, + .max_total_resource_bytes = 268435456, + .max_resource_bytes = 33554432, + .max_destination_operations = 1000000, + }; +} + +fn canvas_spec(key: NodeKey, bounds: Rect, + semantics_policy: CanvasSemanticsPolicy, name: []u8) CanvasSpec { + return CanvasSpec{ + .key = key, + .semantic_parent = semantic_root_key(), + .hit_parent = hit_root_key(), + .bounds = bounds, + .clip = bounds, + .has_clip = 0, + .transform = transform_identity(), + .semantics_policy = semantics_policy, + .name = name, + .description = "", + .hit_testable = 0, + .focusable = 0, + .focus_order = 0, + .enabled = 1, + .z_order = 0, + }; +} + +fn canvas_resource_spec(local_id: i64, kind: RenderResourceKind, + payload: []u8, format_tag: i64) CanvasResourceSpec { + return CanvasResourceSpec{ + .local_id = local_id, + .kind = kind, + .payload = payload, + .format_tag = format_tag, + .width = 0, + .height = 0, + .color_space = RenderColorSpace.none, + }; +} + +fn canvas_mix(hash: i64, value: i64) i64 { + let modulus: i64 = 2147483647; + let component: i64 = value % modulus; + if (component < 0) { component = component + modulus; } + return (hash * 131 + component) % modulus; +} + +fn canvas_text_copy(text: []u8) ArrayList[u8] { + let owned: ArrayList[u8] = make[u8](text.len); + let i: i32 = 0; + while (i < text.len) { + push[u8](&owned, text[i]); + i = i + 1; + } + return owned; +} + +fn canvas_text_hash(hash: i64, text: ArrayList[u8]) i64 { + let result: i64 = canvas_mix(hash, text.len as i64); + let i: i32 = 0; + while (i < text.len) { + result = canvas_mix(result, text.data[i] as i64); + i = i + 1; + } + return result; +} + +fn canvas_text_has_zero(text: []u8) i32 { + let i: i32 = 0; + while (i < text.len) { + if (text[i] == 0) { return 1; } + i = i + 1; + } + return 0; +} + +fn canvas_key_valid(key: NodeKey) i32 { + return (key.value > 0 && key.generation >= 0 && + key.generation <= 2147483647) as i32; +} + +fn canvas_parent_valid(key: NodeKey, root: NodeKey) i32 { + if (node_key_equal(key, root) == 1) { return 1; } + return canvas_key_valid(key); +} + +fn canvas_limits_valid(limits: CanvasLimits) i32 { + return (limits.max_operations >= 0 && + limits.max_operations <= canvas_hard_operation_limit() && + limits.max_destination_operations >= 4 && + limits.max_destination_operations <= canvas_hard_operation_limit() && + limits.max_operations <= limits.max_destination_operations - 4 && + limits.max_resources > 0 && + limits.max_resources <= render_resource_hard_count_limit() && + limits.max_total_resource_bytes > 0 && + limits.max_total_resource_bytes <= render_resource_hard_total_limit() && + limits.max_resource_bytes > 0 && + limits.max_resource_bytes <= render_resource_hard_payload_limit() && + limits.max_resource_bytes <= limits.max_total_resource_bytes) as i32; +} + +fn canvas_clip_inside(bounds: Rect, clip: Rect) i32 { + return (clip.x >= bounds.x && clip.y >= bounds.y && + clip.x + clip.width <= bounds.x + bounds.width && + clip.y + clip.height <= bounds.y + bounds.height) as i32; +} + +fn canvas_transform_point(transform: Transform, x: i64, y: i64) CanvasPoint { + return CanvasPoint{ + .x = (transform.scale_x * x + transform.skew_x * y) / unit_scale() + + transform.translate_x, + .y = (transform.skew_y * x + transform.scale_y * y) / unit_scale() + + transform.translate_y, + }; +} + +fn canvas_min_i64(a: i64, b: i64) i64 { + if (a < b) { return a; } + return b; +} + +fn canvas_max_i64(a: i64, b: i64) i64 { + if (a > b) { return a; } + return b; +} + +fn canvas_transformed_rect(transform: Transform, value: Rect) Rect { + let p0: CanvasPoint = canvas_transform_point(transform, value.x, value.y); + let p1: CanvasPoint = canvas_transform_point(transform, + value.x + value.width, value.y); + let p2: CanvasPoint = canvas_transform_point(transform, + value.x, value.y + value.height); + let p3: CanvasPoint = canvas_transform_point(transform, + value.x + value.width, value.y + value.height); + let minimum_x: i64 = canvas_min_i64(canvas_min_i64(p0.x, p1.x), + canvas_min_i64(p2.x, p3.x)); + let maximum_x: i64 = canvas_max_i64(canvas_max_i64(p0.x, p1.x), + canvas_max_i64(p2.x, p3.x)); + let minimum_y: i64 = canvas_min_i64(canvas_min_i64(p0.y, p1.y), + canvas_min_i64(p2.y, p3.y)); + let maximum_y: i64 = canvas_max_i64(canvas_max_i64(p0.y, p1.y), + canvas_max_i64(p2.y, p3.y)); + return rect(minimum_x, minimum_y, + maximum_x - minimum_x, maximum_y - minimum_y); +} + +fn canvas_spec_effective_clip(spec: CanvasSpec) Rect { + if (spec.has_clip != 0) { return spec.clip; } + return spec.bounds; +} + +fn canvas_spec_world_bounds(spec: CanvasSpec) Rect { + return canvas_transformed_rect(spec.transform, canvas_spec_effective_clip(spec)); +} + +fn canvas_spec_validation(spec: CanvasSpec) CanvasError { + if (canvas_key_valid(spec.key) == 0 || + canvas_parent_valid(spec.semantic_parent, semantic_root_key()) == 0 || + canvas_parent_valid(spec.hit_parent, hit_root_key()) == 0) { + return CanvasError.invalid_key; + } + if (hit_geometry_valid(spec.bounds) == 0) { return CanvasError.invalid_bounds; } + if ((spec.has_clip != 0 && spec.has_clip != 1) || + (spec.has_clip == 1 && (hit_geometry_valid(spec.clip) == 0 || + canvas_clip_inside(spec.bounds, spec.clip) == 0))) { + return CanvasError.invalid_clip; + } + if (hit_transform_values_valid(spec.transform) == 0) { + return CanvasError.invalid_transform; + } + let determinant: i64 = spec.transform.scale_x * spec.transform.scale_y - + spec.transform.skew_x * spec.transform.skew_y; + if (determinant == 0) { return CanvasError.invalid_transform; } + let world: Rect = canvas_spec_world_bounds(spec); + if (world.width <= 0 || world.height <= 0 || + world.x < 0 - unbounded_extent() || world.y < 0 - unbounded_extent() || + world.x > unbounded_extent() - world.width || + world.y > unbounded_extent() - world.height) { + return CanvasError.invalid_transform; + } + if (spec.hit_testable != 0 && + (world.x < -1000000000 || world.y < -1000000000 || + world.x + world.width > 1000000000 || + world.y + world.height > 1000000000)) { + return CanvasError.invalid_transform; + } + if ((spec.hit_testable != 0 && spec.hit_testable != 1) || + (spec.focusable != 0 && spec.focusable != 1) || + (spec.enabled != 0 && spec.enabled != 1) || + (spec.focusable == 1 && (spec.hit_testable == 0 || spec.focus_order <= 0)) || + (spec.focusable == 0 && spec.focus_order != 0) || + spec.focus_order < 0 || spec.z_order < -1000000000 || + spec.z_order > 1000000000) { + return CanvasError.invalid_state; + } + let policy: i64 = spec.semantics_policy as i64; + if (policy < CanvasSemanticsPolicy.named_image as i64 || + policy > CanvasSemanticsPolicy.decorative as i64 || + spec.name.len > canvas_hard_name_limit() || + spec.description.len > canvas_hard_description_limit() || + canvas_text_has_zero(spec.name) != 0 || + canvas_text_has_zero(spec.description) != 0 || + semantic_text_input_valid(spec.name) == 0 || + semantic_text_input_valid(spec.description) == 0) { + return CanvasError.invalid_semantics; + } + if (spec.semantics_policy == CanvasSemanticsPolicy.decorative) { + if (spec.name.len != 0 || spec.description.len != 0 || + spec.hit_testable != 0 || spec.focusable != 0) { + return CanvasError.invalid_semantics; + } + } else if (spec.name.len == 0) { + return CanvasError.invalid_semantics; + } + return CanvasError.none; +} + +fn canvas_owned_spec(canvas: Canvas) CanvasSpec { + return CanvasSpec{ + .key = canvas.key, + .semantic_parent = canvas.semantic_parent, + .hit_parent = canvas.hit_parent, + .bounds = canvas.bounds, + .clip = canvas.clip, + .has_clip = canvas.has_clip, + .transform = canvas.transform, + .semantics_policy = canvas.semantics_policy, + .name = canvas.name.data[0..canvas.name.len], + .description = canvas.description.data[0..canvas.description.len], + .hit_testable = canvas.hit_testable, + .focusable = canvas.focusable, + .focus_order = canvas.focus_order, + .enabled = canvas.enabled, + .z_order = canvas.z_order, + }; +} + +fn canvas_clear_error(canvas: *Canvas) void { + canvas.*.last_error = CanvasError.none; + canvas.*.display_error = DisplayError.none; + canvas.*.resource_error = RenderResourceError.none; + canvas.*.semantics_error = SemanticsError.none; + canvas.*.hit_error = HitTreeError.none; + canvas.*.error_index = 0 - 1; + canvas.*.error_resource_id = 0; +} + +fn canvas_fail(canvas: *Canvas, error: CanvasError) CanvasError { + canvas.*.last_error = error; + return error; +} + +fn canvas_fail_display(canvas: *Canvas, error: DisplayError) CanvasError { + canvas.*.display_error = error; + canvas.*.error_index = canvas.*.display.error_index; + return canvas_fail(canvas, CanvasError.display_failure); +} + +fn canvas_fail_resource(canvas: *Canvas, error: RenderResourceError, + resource_id: i64) CanvasError { + canvas.*.resource_error = error; + canvas.*.error_resource_id = resource_id; + return canvas_fail(canvas, CanvasError.resource_failure); +} + +fn canvas_make(spec: CanvasSpec, limits: CanvasLimits) Canvas { + // Validate borrowed inputs before copying them. Invalid semantic metadata or + // limits must not turn a fail-closed constructor into an attacker-sized + // allocation. + let construction_error: CanvasError = CanvasError.none; + if (canvas_limits_valid(limits) == 0) { + construction_error = CanvasError.invalid_limits; + } else { + construction_error = canvas_spec_validation(spec); + } + let owned_name: ArrayList[u8] = canvas_text_copy(""); + let owned_description: ArrayList[u8] = canvas_text_copy(""); + if (construction_error == CanvasError.none) { + free[u8](&owned_name); + free[u8](&owned_description); + owned_name = canvas_text_copy(spec.name); + owned_description = canvas_text_copy(spec.description); + } + let canvas: Canvas = Canvas{ + .key = spec.key, + .semantic_parent = spec.semantic_parent, + .hit_parent = spec.hit_parent, + .bounds = spec.bounds, + .clip = spec.clip, + .has_clip = spec.has_clip, + .transform = spec.transform, + .semantics_policy = spec.semantics_policy, + .name = owned_name, + .description = owned_description, + .hit_testable = spec.hit_testable, + .focusable = spec.focusable, + .focus_order = spec.focus_order, + .enabled = spec.enabled, + .z_order = spec.z_order, + .limits = limits, + .display = display_list_make_with_resource_limits(limits.max_resources, + limits.max_total_resource_bytes, limits.max_resource_bytes), + .sealed = 0, + .released = 0, + .revision = 0, + .content_hash = 0, + .last_error = CanvasError.none, + .display_error = DisplayError.none, + .resource_error = RenderResourceError.none, + .semantics_error = SemanticsError.none, + .hit_error = HitTreeError.none, + .error_index = 0 - 1, + .error_resource_id = 0, + }; + canvas.last_error = construction_error; + return canvas; +} + +fn canvas_configuration_error(canvas: Canvas) CanvasError { + if (canvas.released != 0) { return CanvasError.released; } + if (canvas_limits_valid(canvas.limits) == 0) { return CanvasError.invalid_limits; } + return canvas_spec_validation(canvas_owned_spec(canvas)); +} + +fn canvas_resource_id(key: NodeKey, local_id: i64) i64 { + if (canvas_key_valid(key) == 0 || local_id <= 0 || + local_id > canvas_local_resource_id_limit()) { return 0; } + let hash: i64 = canvas_mix(17, key.value); + hash = canvas_mix(hash, key.generation); + hash = canvas_mix(hash, local_id); + return (hash % 2147483646) + 1; +} + +fn canvas_add_resource(canvas: *Canvas, source: CanvasResourceSpec) CanvasError { + let configuration: CanvasError = canvas_configuration_error(canvas.*); + if (configuration != CanvasError.none) { return canvas_fail(canvas, configuration); } + if (canvas.*.sealed != 0 || canvas.*.display.sealed != 0) { + return canvas_fail(canvas, CanvasError.sealed); + } + let resource_id: i64 = canvas_resource_id(canvas.*.key, source.local_id); + if (resource_id <= 0) { + return canvas_fail(canvas, CanvasError.invalid_resource_id); + } + let spec: RenderResourceSpec = render_resource_spec(resource_id, + source.kind, source.payload, source.format_tag); + spec.width = source.width; + spec.height = source.height; + spec.color_space = source.color_space; + let error: RenderResourceError = display_list_add_resource(&canvas.*.display, spec); + if (error != RenderResourceError.none) { + return canvas_fail_resource(canvas, error, resource_id); + } + canvas_clear_error(canvas); + return CanvasError.none; +} + +fn canvas_op(canvas: Canvas, kind: DisplayOpKind) DisplayOp { + return display_op(kind, canvas.key); +} + +fn canvas_resource_op(canvas: Canvas, kind: DisplayOpKind, + local_resource_id: i64) DisplayOp { + let op: DisplayOp = canvas_op(canvas, kind); + op.resource_id = canvas_resource_id(canvas.key, local_resource_id); + return op; +} + +fn canvas_push(canvas: *Canvas, op: DisplayOp) CanvasError { + let configuration: CanvasError = canvas_configuration_error(canvas.*); + if (configuration != CanvasError.none) { return canvas_fail(canvas, configuration); } + if (canvas.*.sealed != 0 || canvas.*.display.sealed != 0) { + return canvas_fail(canvas, CanvasError.sealed); + } + if (canvas.*.display.ops.len >= canvas.*.limits.max_operations) { + canvas.*.error_index = canvas.*.display.ops.len; + return canvas_fail(canvas, CanvasError.too_many_operations); + } + if (node_key_equal(op.owner, canvas.*.key) == 0) { + canvas.*.error_index = canvas.*.display.ops.len; + return canvas_fail(canvas, CanvasError.invalid_owner); + } + let error: DisplayError = display_list_push(&canvas.*.display, op); + if (error != DisplayError.none) { return canvas_fail_display(canvas, error); } + canvas_clear_error(canvas); + return CanvasError.none; +} + +fn canvas_calculate_hash(canvas: Canvas) i64 { + let hash: i64 = canvas_mix(17, canvas.key.value); + hash = canvas_mix(hash, canvas.key.generation); + hash = canvas_mix(hash, canvas.semantic_parent.value); + hash = canvas_mix(hash, canvas.semantic_parent.generation); + hash = canvas_mix(hash, canvas.hit_parent.value); + hash = canvas_mix(hash, canvas.hit_parent.generation); + hash = canvas_mix(hash, canvas.bounds.x); + hash = canvas_mix(hash, canvas.bounds.y); + hash = canvas_mix(hash, canvas.bounds.width); + hash = canvas_mix(hash, canvas.bounds.height); + hash = canvas_mix(hash, canvas.clip.x); + hash = canvas_mix(hash, canvas.clip.y); + hash = canvas_mix(hash, canvas.clip.width); + hash = canvas_mix(hash, canvas.clip.height); + hash = canvas_mix(hash, canvas.has_clip as i64); + hash = canvas_mix(hash, canvas.transform.scale_x); + hash = canvas_mix(hash, canvas.transform.skew_x); + hash = canvas_mix(hash, canvas.transform.skew_y); + hash = canvas_mix(hash, canvas.transform.scale_y); + hash = canvas_mix(hash, canvas.transform.translate_x); + hash = canvas_mix(hash, canvas.transform.translate_y); + hash = canvas_mix(hash, canvas.semantics_policy as i64); + hash = canvas_text_hash(hash, canvas.name); + hash = canvas_text_hash(hash, canvas.description); + hash = canvas_mix(hash, canvas.hit_testable as i64); + hash = canvas_mix(hash, canvas.focusable as i64); + hash = canvas_mix(hash, canvas.focus_order); + hash = canvas_mix(hash, canvas.enabled as i64); + hash = canvas_mix(hash, canvas.z_order); + hash = canvas_mix(hash, canvas.limits.max_operations as i64); + hash = canvas_mix(hash, canvas.limits.max_resources as i64); + hash = canvas_mix(hash, canvas.limits.max_total_resource_bytes); + hash = canvas_mix(hash, canvas.limits.max_resource_bytes); + hash = canvas_mix(hash, canvas.limits.max_destination_operations as i64); + hash = canvas_mix(hash, canvas.display.content_hash); + return canvas_mix(hash, canvas.revision); +} + +fn canvas_seal(canvas: *Canvas) CanvasError { + let configuration: CanvasError = canvas_configuration_error(canvas.*); + if (configuration != CanvasError.none) { return canvas_fail(canvas, configuration); } + if (canvas.*.sealed != 0) { + if (canvas_verify(canvas) == CanvasError.none) { return CanvasError.none; } + return canvas.*.last_error; + } + let error: DisplayError = display_list_seal(&canvas.*.display); + if (error != DisplayError.none) { return canvas_fail_display(canvas, error); } + canvas.*.sealed = 1; + canvas.*.revision = canvas.*.display.revision + 1; + canvas.*.content_hash = canvas_calculate_hash(canvas.*); + canvas_clear_error(canvas); + return CanvasError.none; +} + +fn canvas_verify(canvas: *Canvas) CanvasError { + let configuration: CanvasError = canvas_configuration_error(canvas.*); + if (configuration != CanvasError.none) { return canvas_fail(canvas, configuration); } + if (canvas.*.sealed == 0 || canvas.*.display.sealed == 0) { + return canvas_fail(canvas, CanvasError.not_sealed); + } + if (canvas.*.display.ops.len > canvas.*.limits.max_operations || + canvas.*.display.resources.resources.len > canvas.*.limits.max_resources || + canvas.*.display.resources.total_bytes > canvas.*.limits.max_total_resource_bytes || + canvas.*.revision != canvas.*.display.revision + 1) { + return canvas_fail(canvas, CanvasError.content_mutated); + } + let display_error: DisplayError = display_list_verify(canvas.*.display); + if (display_error != DisplayError.none) { + canvas.*.display_error = display_error; + return canvas_fail(canvas, CanvasError.content_mutated); + } + let i: i32 = 0; + while (i < canvas.*.display.ops.len) { + if (node_key_equal(canvas.*.display.ops.data[i].owner, canvas.*.key) == 0) { + canvas.*.error_index = i; + return canvas_fail(canvas, CanvasError.content_mutated); + } + i = i + 1; + } + if (canvas.*.content_hash != canvas_calculate_hash(canvas.*)) { + return canvas_fail(canvas, CanvasError.content_mutated); + } + canvas_clear_error(canvas); + return CanvasError.none; +} + +fn canvas_replay_hash(canvas: *Canvas) i64 { + if (canvas_verify(canvas) != CanvasError.none) { return 0; } + return canvas.*.content_hash; +} + +fn canvas_retained_spec(canvas: *Canvas) CanvasRetainedResult { + let fallback: ViewSpec = view_spec(canvas.*.key, canvas_render_kind(), 0); + let error: CanvasError = canvas_verify(canvas); + if (error != CanvasError.none) { + return CanvasRetainedResult{ .spec = fallback, .error = error }; + } + return CanvasRetainedResult{ + .spec = view_spec(canvas.*.key, canvas_render_kind(), canvas.*.content_hash), + .error = CanvasError.none, + }; +} + +fn canvas_effective_clip(canvas: Canvas) Rect { + if (canvas.has_clip != 0) { return canvas.clip; } + return canvas.bounds; +} + +fn canvas_destination_preflight(canvas: Canvas, + destination: DisplayList) CanvasError { + if (destination.sealed != 0 || destination.resources.sealed != 0) { + return CanvasError.destination_sealed; + } + if (render_resource_store_configuration_valid(destination.resources) == 0 || + destination.stack_depth < 0 || destination.layer_depth < 0) { + return CanvasError.destination_limit; + } + let contribution_operations: i32 = canvas.display.ops.len + 4; + if (destination.ops.len > canvas.limits.max_destination_operations - + contribution_operations) { + return CanvasError.destination_limit; + } + if (destination.resources.resources.len > + destination.resources.max_resources - canvas.display.resources.resources.len || + canvas.display.resources.total_bytes > + destination.resources.max_total_bytes - destination.resources.total_bytes) { + return CanvasError.destination_limit; + } + let i: i32 = 0; + while (i < canvas.display.resources.resources.len) { + let resource: RenderResource = canvas.display.resources.resources.data[i]; + if (render_resource_find_index(destination.resources, resource.id) >= 0) { + return CanvasError.resource_collision; + } + if (resource.payload.len as i64 > destination.resources.max_payload_bytes) { + return CanvasError.destination_limit; + } + i = i + 1; + } + return CanvasError.none; +} + +fn canvas_copy_resource_to_display(resource: RenderResource, + destination: *DisplayList) RenderResourceError { + let spec: RenderResourceSpec = render_resource_spec(resource.id, + resource.kind, resource.payload.data[0..resource.payload.len], + resource.format_tag); + spec.width = resource.width; + spec.height = resource.height; + spec.color_space = resource.color_space; + return display_list_add_resource(destination, spec); +} + +fn canvas_push_destination(canvas: *Canvas, destination: *DisplayList, + op: DisplayOp) CanvasError { + let error: DisplayError = display_list_push(destination, op); + if (error != DisplayError.none) { + canvas.*.display_error = error; + canvas.*.error_index = destination.*.error_index; + return canvas_fail(canvas, CanvasError.display_failure); + } + return CanvasError.none; +} + +fn canvas_contribute_display(canvas: *Canvas, + destination: *DisplayList) CanvasError { + let verified: CanvasError = canvas_verify(canvas); + if (verified != CanvasError.none) { return verified; } + let preflight: CanvasError = canvas_destination_preflight(canvas.*, destination.*); + if (preflight != CanvasError.none) { + if (preflight == CanvasError.resource_collision) { + let i: i32 = 0; + while (i < canvas.*.display.resources.resources.len) { + let id: i64 = canvas.*.display.resources.resources.data[i].id; + if (render_resource_find_index(destination.*.resources, id) >= 0) { + canvas.*.error_resource_id = id; + break; + } + i = i + 1; + } + } + return canvas_fail(canvas, preflight); + } + + let resource_index: i32 = 0; + while (resource_index < canvas.*.display.resources.resources.len) { + let resource: RenderResource = + canvas.*.display.resources.resources.data[resource_index]; + let resource_error: RenderResourceError = + canvas_copy_resource_to_display(resource, destination); + if (resource_error != RenderResourceError.none) { + return canvas_fail_resource(canvas, resource_error, resource.id); + } + resource_index = resource_index + 1; + } + + let save: DisplayOp = display_op(DisplayOpKind.save, canvas.*.key); + let result: CanvasError = canvas_push_destination(canvas, destination, save); + if (result != CanvasError.none) { return result; } + let transform: DisplayOp = display_op(DisplayOpKind.concat_transform, canvas.*.key); + transform.transform = canvas.*.transform; + result = canvas_push_destination(canvas, destination, transform); + if (result != CanvasError.none) { return result; } + let clip: DisplayOp = display_op(DisplayOpKind.clip_rect, canvas.*.key); + clip.bounds = canvas_effective_clip(canvas.*); + result = canvas_push_destination(canvas, destination, clip); + if (result != CanvasError.none) { return result; } + + let operation_index: i32 = 0; + while (operation_index < canvas.*.display.ops.len) { + result = canvas_push_destination(canvas, destination, + canvas.*.display.ops.data[operation_index]); + if (result != CanvasError.none) { return result; } + operation_index = operation_index + 1; + } + let restore: DisplayOp = display_op(DisplayOpKind.restore, canvas.*.key); + result = canvas_push_destination(canvas, destination, restore); + if (result != CanvasError.none) { return result; } + canvas_clear_error(canvas); + return CanvasError.none; +} + +fn canvas_semantic_role(policy: CanvasSemanticsPolicy) SemanticRole { + return switch (policy) { + .named_image => SemanticRole.image, + .named_group => SemanticRole.group, + .decorative => SemanticRole.group, + }; +} + +fn canvas_contribute_semantics(canvas: *Canvas, + tree: *SemanticsTree) CanvasError { + let verified: CanvasError = canvas_verify(canvas); + if (verified != CanvasError.none) { return verified; } + let spec: SemanticsSpec = semantics_spec(canvas.*.key, + canvas.*.semantic_parent, canvas_semantic_role(canvas.*.semantics_policy), + canvas.*.name.data[0..canvas.*.name.len]); + spec.description = canvas.*.description.data[0..canvas.*.description.len]; + spec.has_bounds = 1; + spec.bounds = canvas_transformed_rect(canvas.*.transform, + canvas_effective_clip(canvas.*)); + spec.disabled = (canvas.*.enabled == 0) as i32; + if (canvas.*.semantics_policy == CanvasSemanticsPolicy.decorative) { + spec.hidden = 1; + } + if (canvas.*.focusable != 0) { + spec.focus_order = canvas.*.focus_order; + if (canvas.*.enabled != 0) { + spec.action_mask = semantic_actions(SemanticAction.focus); + } + } + let error: SemanticsError = semantics_add(tree, spec); + if (error != SemanticsError.none) { + canvas.*.semantics_error = error; + return canvas_fail(canvas, CanvasError.semantics_failure); + } + canvas_clear_error(canvas); + return CanvasError.none; +} + +fn canvas_hit_node(canvas: Canvas) HitNode { + let node: HitNode = hit_node(canvas.key, canvas.hit_parent, canvas.bounds); + node.has_clip = 1; + node.clip = canvas_effective_clip(canvas); + node.transform = canvas.transform; + node.z_order = canvas.z_order; + node.enabled = canvas.enabled; + node.focusable = canvas.focusable; + return node; +} + +fn canvas_local_from_world(canvas: *Canvas, world_x: i64, + world_y: i64) CanvasLocalResult { + let verified: CanvasError = canvas_verify(canvas); + if (verified != CanvasError.none) { + return CanvasLocalResult{ .found = 0, .x = 0, .y = 0, .error = verified }; + } + if (canvas.*.hit_testable == 0 || canvas.*.enabled == 0) { + canvas_fail(canvas, CanvasError.not_interactive); + return CanvasLocalResult{ .found = 0, .x = 0, .y = 0, + .error = CanvasError.not_interactive }; + } + let node: HitNode = canvas_hit_node(canvas.*); + let hit: HitResult = hit_node_local(node, world_x, world_y); + if (hit.found == 0 || rect_contains(node.bounds, hit.local_x, hit.local_y) == 0 || + rect_contains(node.clip, hit.local_x, hit.local_y) == 0) { + canvas_clear_error(canvas); + return CanvasLocalResult{ .found = 0, .x = 0, .y = 0, + .error = CanvasError.none }; + } + canvas_clear_error(canvas); + return CanvasLocalResult{ .found = 1, .x = hit.local_x, + .y = hit.local_y, .error = CanvasError.none }; +} + +fn canvas_contribute_hit(canvas: *Canvas, tree: *HitTree) CanvasError { + let verified: CanvasError = canvas_verify(canvas); + if (verified != CanvasError.none) { return verified; } + if (canvas.*.hit_testable == 0) { + return canvas_fail(canvas, CanvasError.not_interactive); + } + let error: HitTreeError = hit_tree_add(tree, canvas_hit_node(canvas.*)); + if (error != HitTreeError.none) { + canvas.*.hit_error = error; + return canvas_fail(canvas, CanvasError.hit_failure); + } + canvas_clear_error(canvas); + return CanvasError.none; +} + +fn canvas_free(canvas: *Canvas) CanvasError { + if (canvas.*.released != 0) { + canvas.*.last_error = CanvasError.released; + return CanvasError.released; + } + display_list_free(&canvas.*.display); + free[u8](&canvas.*.name); + free[u8](&canvas.*.description); + canvas.*.released = 1; + canvas.*.sealed = 0; + canvas.*.content_hash = 0; + canvas.*.last_error = CanvasError.none; + return CanvasError.none; +} diff --git a/src/design/tokens.zag b/src/design/tokens.zag index 9412dcb..67efbac 100644 --- a/src/design/tokens.zag +++ b/src/design/tokens.zag @@ -22,6 +22,7 @@ enum SemanticColorToken { focus, status_healthy, status_experimental, + status_error, category_semantics, category_motion, category_input, @@ -42,7 +43,7 @@ struct ResolvedColorToken { paint: Paint, } -fn semantic_color_token_count() i32 { return 30; } +fn semantic_color_token_count() i32 { return 31; } fn semantic_color(token: SemanticColorToken) ResolvedColorToken { return switch (token) { @@ -80,6 +81,8 @@ fn semantic_color(token: SemanticColorToken) ResolvedColorToken { .paint = paint_rgba16(18000, 56000, 42000, 65535) }, .status_experimental => ResolvedColorToken{ .id = "color.status.experimental", .paint = paint_rgba16(65535, 43000, 17000, 65535) }, + .status_error => ResolvedColorToken{ .id = "color.status.error", + .paint = paint_rgba16(65535, 17500, 23500, 65535) }, .category_semantics => ResolvedColorToken{ .id = "color.category.semantics", .paint = paint_rgba16(15000, 48000, 65535, 65535) }, .category_motion => ResolvedColorToken{ .id = "color.category.motion", diff --git a/src/semantics/semantics.zag b/src/semantics/semantics.zag index bff6c57..903b744 100644 --- a/src/semantics/semantics.zag +++ b/src/semantics/semantics.zag @@ -1,6 +1,7 @@ @import("std:list") @import("../core/view_contract.zag") @import("../core/geometry.zag") +@import("../text/unicode.zag") enum SemanticRole { application, @@ -53,6 +54,7 @@ enum SemanticsError { duplicate_focus_order, invalid_range, invalid_text_selection, + invalid_text, invalid_bounds, invalid_relationship, invalid_collection, @@ -254,6 +256,15 @@ fn semantic_text_equal(actual: ArrayList[u8], expected: []u8) i32 { return 1; } +fn semantic_text_input_valid(text: []u8) i32 { + let i: i32 = 0; + while (i < text.len) { + if (text[i] == 0) { return 0; } + i = i + 1; + } + return (utf8_validate(text).error == Utf8Error.none) as i32; +} + fn semantic_evidence_mix(hash: i64, value: i64) i64 { return (hash * 1099511628211) ^ value; } @@ -386,6 +397,11 @@ fn semantics_add(tree: *SemanticsTree, spec: SemanticsSpec) SemanticsError { if (semantics_find_index(tree.*, spec.id) >= 0) { return semantics_fail(tree, SemanticsError.duplicate_id, spec.id); } + if (semantic_text_input_valid(spec.name) == 0 || + semantic_text_input_valid(spec.description) == 0 || + semantic_text_input_valid(spec.value) == 0) { + return semantics_fail(tree, SemanticsError.invalid_text, spec.id); + } if (node_key_equal(spec.parent, semantic_root_key()) == 0 && semantics_find_index(tree.*, spec.parent) < 0) { return semantics_fail(tree, SemanticsError.missing_parent, spec.id); diff --git a/src/text/unicode.zag b/src/text/unicode.zag index d528fc4..a803980 100644 --- a/src/text/unicode.zag +++ b/src/text/unicode.zag @@ -27,6 +27,12 @@ struct Utf8DecodeResult { error_offset: i64, } +struct Utf8ValidationResult { + error: Utf8Error, + error_offset: i64, + scalar_count: i64, +} + fn unicode_scalar(value: i64, start: i64, length: i32) UnicodeScalar { return UnicodeScalar{ .value = value, @@ -52,6 +58,80 @@ fn utf8_continuation(byte: i32) i32 { return ((byte & 192) == 128) as i32; } +// Allocation-free validation for metadata paths that need strict Unicode +// truth but do not need to retain decoded scalars (semantics, IDs, labels). +fn utf8_validate(bytes: []u8) Utf8ValidationResult { + let result: Utf8ValidationResult = Utf8ValidationResult{ + .error = Utf8Error.none, .error_offset = 0, .scalar_count = 0, + }; + if (bytes.len > 1048576) { + result.error = Utf8Error.input_too_large; + result.error_offset = bytes.len as i64; + return result; + } + let offset: i32 = 0; + while (offset < bytes.len) { + let lead: i32 = bytes[offset] as i32; + let length: i32 = 0; + let value: i64 = 0; + let minimum: i64 = 0; + if (lead < 128) { + length = 1; + value = lead as i64; + } else if (lead >= 194 && lead <= 223) { + length = 2; + value = (lead & 31) as i64; + minimum = 128; + } else if (lead >= 224 && lead <= 239) { + length = 3; + value = (lead & 15) as i64; + minimum = 2048; + } else if (lead >= 240 && lead <= 244) { + length = 4; + value = (lead & 7) as i64; + minimum = 65536; + } else { + result.error = Utf8Error.invalid_lead; + result.error_offset = offset as i64; + return result; + } + if (offset + length > bytes.len) { + result.error = Utf8Error.truncated; + result.error_offset = offset as i64; + return result; + } + let continuation_index: i32 = 1; + while (continuation_index < length) { + let continuation: i32 = bytes[offset + continuation_index] as i32; + if (utf8_continuation(continuation) == 0) { + result.error = Utf8Error.invalid_continuation; + result.error_offset = (offset + continuation_index) as i64; + return result; + } + value = (value << 6) | ((continuation & 63) as i64); + continuation_index = continuation_index + 1; + } + if (length > 1 && value < minimum) { + result.error = Utf8Error.overlong; + result.error_offset = offset as i64; + return result; + } + if (value >= 55296 && value <= 57343) { + result.error = Utf8Error.surrogate; + result.error_offset = offset as i64; + return result; + } + if (value > 1114111) { + result.error = Utf8Error.out_of_range; + result.error_offset = offset as i64; + return result; + } + result.scalar_count = result.scalar_count + 1; + offset = offset + length; + } + return result; +} + fn utf8_decode(bytes: []u8) Utf8DecodeResult { let result: Utf8DecodeResult = utf8_result(); if (bytes.len > 1048576) { diff --git a/tests/button_contract.zag b/tests/button_contract.zag new file mode 100644 index 0000000..8945013 --- /dev/null +++ b/tests/button_contract.zag @@ -0,0 +1,267 @@ +@import("../src/components/button.zag") +@import("../src/automation/talkback.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn make_button(id: i64, canonical: ButtonCanonicalState) ButtonSpec { + let spec: ButtonSpec = button_spec(node_key(id), hit_root_key(), + rect(20 * unit_scale(), id * 4 * unit_scale(), + 160 * unit_scale(), 40 * unit_scale()), "Run analysis"); + spec.description = "Runs the active analysis"; + spec.focus_order = id; + spec.z_order = id; + spec.interaction = button_canonical_state(canonical); + return spec; +} + +fn measurement_contract(state: *TestState) void { + let loose: Constraints = Constraints{ + .min_width = 0, .max_width = unbounded_extent(), + .min_height = 0, .max_height = unbounded_extent(), + }; + let standard: ButtonMeasureResult = button_measure( + size(72 * unit_scale(), 16 * unit_scale()), 0, loose, + FlexDensity.standard); + expect(state, standard.error == ButtonMeasureError.none && + standard.size.width == 104 * unit_scale() && + standard.size.height == 40 * unit_scale(), + "Button measurement resolves label content through Flex padding and the standard target"); + let icon: ButtonMeasureResult = button_measure( + size(72 * unit_scale(), 16 * unit_scale()), 1, loose, + FlexDensity.standard); + expect(state, icon.size.width == 130 * unit_scale() && + icon.visual_gap == 8 * unit_scale(), + "leading visuals use the named Flex gap instead of component-local offsets"); + let touch: ButtonMeasureResult = button_measure(size(0, 0), 1, + tight_constraints(48 * unit_scale(), 48 * unit_scale()), + FlexDensity.touch); + expect(state, touch.minimum_target == 48 * unit_scale() && + touch.size.width == 48 * unit_scale() && touch.clipped == 0, + "touch density preserves a 48 logical-pixel interaction target"); + let clipped: ButtonMeasureResult = button_measure( + size(100 * unit_scale(), 20 * unit_scale()), 0, + tight_constraints(40 * unit_scale(), 24 * unit_scale()), + FlexDensity.compact); + expect(state, clipped.clipped == 1 && clipped.size.width == 40 * unit_scale(), + "constraint clipping is explicit rather than silently changing intrinsic content"); + let invalid: ButtonMeasureResult = button_measure(size(0 - 1, 10), 0, + loose, FlexDensity.standard); + expect(state, invalid.error == ButtonMeasureError.invalid_content, + "invalid intrinsic content fails visibly"); +} + +fn transition_contract(state: *TestState) void { + let current: ButtonInteraction = button_interaction(); + let hover: ButtonTransition = button_reduce(current, ButtonEvent.pointer_enter); + let down: ButtonTransition = button_reduce(hover.interaction, ButtonEvent.pointer_down); + let up: ButtonTransition = button_reduce(down.interaction, ButtonEvent.pointer_up_inside); + expect(state, hover.interaction.hovered == 1 && down.interaction.pressed == 1 && + up.interaction.pressed == 0 && up.activate == 1, + "pointer enter down and inside-up produce one interruptible activation transition"); + let cancel: ButtonTransition = button_reduce(down.interaction, ButtonEvent.pointer_cancel); + expect(state, cancel.interaction.pressed == 0 && cancel.activate == 0, + "pointer cancellation clears press without emitting an action"); + let disabled: ButtonTransition = button_reduce(current, ButtonEvent.disable); + let ignored: ButtonTransition = button_reduce(disabled.interaction, ButtonEvent.pointer_down); + expect(state, disabled.interaction.enabled == 0 && + disabled.interaction.focused == 0 && ignored.interaction.pressed == 0, + "disabled state rejects pointer interaction at the shared state reducer"); + let loading: ButtonTransition = button_reduce(down.interaction, ButtonEvent.begin_loading); + let failure: ButtonTransition = button_reduce(loading.interaction, ButtonEvent.mark_error); + expect(state, loading.interaction.loading == 1 && loading.interaction.pressed == 0 && + failure.interaction.loading == 0 && failure.interaction.error == 1, + "loading and error transitions have deterministic precedence"); + let selected: ButtonTransition = button_reduce(current, ButtonEvent.select); + expect(state, selected.interaction.selected == 1 && selected.state_changed == 1 && + button_reduce(selected.interaction, ButtonEvent.select).state_changed == 0, + "selection is parent-controlled state and repeated selection is idempotent"); +} + +fn state_matrix_contract(state: *TestState) void { + let display: DisplayList = display_list_make(); + let hits: HitTree = hit_tree_make(); + let semantics: SemanticsTree = semantics_tree_make(); + let first_hash: i64 = 0; + let index: i32 = 0; + while (index < 8) { + let spec: ButtonSpec = make_button(10 + index, + index as ButtonCanonicalState); + if (index == 0) { spec.variant = ButtonVariant.primary; } + if (index == 7) { spec.variant = ButtonVariant.destructive; } + let built: ButtonBuildResult = button_emit(spec, &display, &hits, &semantics); + expect(state, built.error == ButtonBuildError.none && + built.artifact.id.value == 10 + index && + built.artifact.hit_node_index == index && + built.artifact.semantics_node_index == index, + "each canonical Button state emits one shared stable identity"); + expect(state, built.artifact.evidence_hash != 0 && + (index == 0 || built.artifact.evidence_hash != first_hash), + "component state and token truth are present in deterministic evidence"); + if (index == 0) { first_hash = built.artifact.evidence_hash; } + index = index + 1; + } + expect(state, hits.nodes.len == 8 && semantics.nodes.len == 8, + "the canonical state matrix contributes equal hit and semantic trees"); + expect(state, semantics.nodes.data[5].disabled == 1 && + semantics.nodes.data[5].action_mask == 0 && + hits.nodes.data[5].enabled == 0 && hits.nodes.data[5].focusable == 0, + "disabled Button truth agrees across semantics focus actions and hit testing"); + expect(state, semantics.nodes.data[6].disabled == 1 && + semantic_text_equal(semantics.nodes.data[6].value, "Loading") != 0 && + hits.nodes.data[6].enabled == 0, + "loading Button stays discoverable while actions fail closed"); + expect(state, semantics.nodes.data[7].disabled == 0 && + semantic_text_equal(semantics.nodes.data[7].value, "Error") != 0 && + display.ops.data[display.ops.len - 2].paint.red == + semantic_color_paint(SemanticColorToken.status_error).red, + "error state exposes text truth and the named error token"); + let selected_index: i32 = semantics_find_index(semantics, node_key(14)); + expect(state, selected_index >= 0 && semantics.nodes.data[selected_index].selected == 1, + "selected state is semantic state rather than color alone"); + + let hit: HitResult = hit_test(hits, 30 * unit_scale(), 40 * unit_scale()); + expect(state, hit.found != 0 && hit.id.value == 10, + "pointer hit testing resolves the same stable ID used by rendering and semantics"); + expect(state, display_list_seal(&display) == DisplayError.none && + display_list_verify(display) == DisplayError.none, + "Button chrome produces an immutable verifiable DisplayList"); + + let session: TalkbackSession = talkback_session_make(talkback_capabilities()); + let viewport: Rect = rect(0, 0, 600 * unit_scale(), 600 * unit_scale()); + let enabled_click: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(1, TalkbackCommand.click, node_key(10), + semantics.revision)); + let disabled_click: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(2, TalkbackCommand.click, node_key(15), + semantics.revision)); + expect(state, enabled_click.status == TalkbackStatus.accepted && + enabled_click.emitted_action == 1, + "Zagkit Talkback activates Button by stable ID"); + expect(state, disabled_click.status == TalkbackStatus.action_unavailable && + disabled_click.emitted_action == 0, + "Zagkit Talkback observes the same disabled action truth"); + talkback_session_free(&session); + semantics_tree_free(&semantics); + hit_tree_free(&hits); + display_list_free(&display); +} + +fn atomicity_contract(state: *TestState) void { + let display: DisplayList = display_list_make(); + let hits: HitTree = hit_tree_make(); + let semantics: SemanticsTree = semantics_tree_make(); + let first: ButtonBuildResult = button_emit(make_button(30, + ButtonCanonicalState.rest), &display, &hits, &semantics); + let op_count: i32 = display.ops.len; + let display_hash: i64 = display.content_hash; + let hit_count: i32 = hits.nodes.len; + let semantic_count: i32 = semantics.nodes.len; + let duplicate: ButtonBuildResult = button_emit(make_button(30, + ButtonCanonicalState.hover), &display, &hits, &semantics); + expect(state, first.error == ButtonBuildError.none && + duplicate.error == ButtonBuildError.hit_error && + duplicate.hit_error == HitTreeError.duplicate_id, + "duplicate stable IDs fail visibly at the retained hit tree"); + expect(state, display.ops.len == op_count && display.content_hash == display_hash && + hits.nodes.len == hit_count && semantics.nodes.len == semantic_count, + "failed multi-tree Button emission rolls back all partial artifacts"); + + let colliding: ButtonSpec = make_button(31, ButtonCanonicalState.rest); + colliding.focus_order = 30; + let focus_collision: ButtonBuildResult = button_emit(colliding, + &display, &hits, &semantics); + expect(state, focus_collision.error == ButtonBuildError.semantics_error && + focus_collision.semantics_error == SemanticsError.duplicate_focus_order, + "semantic focus-order collisions remain structured component failures"); + expect(state, display.ops.len == op_count && hits.nodes.len == hit_count && + semantics.nodes.len == semantic_count, + "semantic failure also restores display and hit-tree checkpoints"); + + let missing: ButtonSpec = make_button(32, ButtonCanonicalState.rest); + missing.parent = node_key(999); + let missing_parent: ButtonBuildResult = button_emit(missing, + &display, &hits, &semantics); + expect(state, missing_parent.error == ButtonBuildError.hit_error && + missing_parent.hit_error == HitTreeError.missing_parent && + display.ops.len == op_count, + "missing retained parents fail without leaving decorative chrome behind"); + + let invalid: ButtonSpec = make_button(33, ButtonCanonicalState.rest); + invalid.label = ""; + expect(state, button_emit(invalid, &display, &hits, &semantics).error == + ButtonBuildError.invalid_spec, + "unlabelled public Buttons are rejected instead of creating inaccessible controls"); + + let malformed_bytes: ArrayList[u8] = make[u8](2); + push[u8](&malformed_bytes, 194); push[u8](&malformed_bytes, 65); + let malformed: ButtonSpec = make_button(34, ButtonCanonicalState.rest); + malformed.label = malformed_bytes.data[0..malformed_bytes.len]; + expect(state, button_emit(malformed, &display, &hits, &semantics).error == + ButtonBuildError.invalid_spec && display.ops.len == op_count, + "malformed UTF-8 labels fail before component-tree mutation"); + free[u8](&malformed_bytes); + + let oversized_bytes: ArrayList[u8] = make[u8](4100); + let byte_index: i32 = 0; + while (byte_index < 4097) { + push[u8](&oversized_bytes, 65); + byte_index = byte_index + 1; + } + let oversized: ButtonSpec = make_button(35, ButtonCanonicalState.rest); + oversized.label = oversized_bytes.data[0..oversized_bytes.len]; + expect(state, button_emit(oversized, &display, &hits, &semantics).error == + ButtonBuildError.invalid_spec && semantics.nodes.len == semantic_count, + "oversized labels fail before semantic text ownership"); + free[u8](&oversized_bytes); + _ = display_list_seal(&display); + let sealed: ButtonBuildResult = button_emit(make_button(36, + ButtonCanonicalState.rest), &display, &hits, &semantics); + expect(state, sealed.error == ButtonBuildError.display_error && + sealed.display_error == DisplayError.sealed, + "sealed display truth rejects late component mutation"); + semantics_tree_free(&semantics); + hit_tree_free(&hits); + display_list_free(&display); +} + +fn token_and_content_contract(state: *TestState) void { + let primary: ButtonSpec = make_button(50, ButtonCanonicalState.rest); + primary.variant = ButtonVariant.primary; + let hover: ButtonSpec = make_button(51, ButtonCanonicalState.hover); + let disabled: ButtonSpec = make_button(52, ButtonCanonicalState.disabled); + let error: ButtonSpec = make_button(53, ButtonCanonicalState.error); + expect(state, button_style(primary).fill_token == SemanticColorToken.accent && + button_style(hover).fill_token == SemanticColorToken.surface_selected, + "primary and hover styling resolve through distinct semantic roles"); + expect(state, button_style(disabled).text_token == SemanticColorToken.text_disabled && + button_style(error).border_token == SemanticColorToken.status_error, + "disabled and error states use documented semantic tokens"); + let content: Rect = button_content_bounds(primary); + expect(state, content.x == primary.bounds.x + 16 * unit_scale() && + content.y == primary.bounds.y + 8 * unit_scale() && + content.width == primary.bounds.width - 32 * unit_scale(), + "Button exposes exact Flex-derived bounds for caller-owned Text content"); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + measurement_contract(&state); + transition_contract(&state); + state_matrix_contract(&state); + atomicity_contract(&state); + token_and_content_contract(&state); + _zag_print("Button contract: pass="); _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tests/canvas_contract.zag b/tests/canvas_contract.zag new file mode 100644 index 0000000..83b2272 --- /dev/null +++ b/tests/canvas_contract.zag @@ -0,0 +1,504 @@ +@import("../src/components/canvas.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn test_canvas(key: NodeKey, red: i64) Canvas { + let spec: CanvasSpec = canvas_spec(key, + rect(0, 0, 200 * unit_scale(), 100 * unit_scale()), + CanvasSemanticsPolicy.named_group, "CAD viewport"); + let canvas: Canvas = canvas_make(spec, canvas_limits_default()); + let fill: DisplayOp = canvas_op(canvas, DisplayOpKind.fill_rect); + fill.bounds = rect(0, 0, 200 * unit_scale(), 100 * unit_scale()); + fill.paint = paint_rgba16(red, 20000, 30000, 65535); + _ = canvas_push(&canvas, fill); + _ = canvas_seal(&canvas); + return canvas; +} + +fn retained_and_replay_contract(state: *TestState) void { + let first: Canvas = test_canvas(node_key_scoped(100, 7), 10000); + let repeated: Canvas = test_canvas(node_key_scoped(100, 7), 10000); + let changed: Canvas = test_canvas(node_key_scoped(100, 7), 10001); + let other_generation: Canvas = test_canvas(node_key_scoped(100, 8), 10000); + expect(state, canvas_verify(&first) == CanvasError.none && + canvas_replay_hash(&first) == canvas_replay_hash(&repeated), + "identical Canvas frames replay to one deterministic identity"); + expect(state, first.content_hash != changed.content_hash && + first.content_hash != other_generation.content_hash, + "paint and stable-key generation changes alter Canvas identity"); + + let first_view: CanvasRetainedResult = canvas_retained_spec(&first); + let changed_view: CanvasRetainedResult = canvas_retained_spec(&changed); + expect(state, first_view.error == CanvasError.none && + node_key_equal(first_view.spec.key, first.key) == 1 && + first_view.spec.kind == canvas_render_kind(), + "Canvas exposes its exact NodeKey through the retained ViewSpec bridge"); + + let empty: ArrayList[RenderNode] = make[RenderNode](1); + let first_specs: ArrayList[ViewSpec] = make[ViewSpec](1); + push[ViewSpec](&first_specs, first_view.spec); + let next_instance_id: i64 = 1; + let inserted: ReconcileResult = reconcile(empty, first_specs, &next_instance_id); + let reused: ReconcileResult = reconcile(inserted.nodes, first_specs, &next_instance_id); + let changed_specs: ArrayList[ViewSpec] = make[ViewSpec](1); + push[ViewSpec](&changed_specs, changed_view.spec); + let updated: ReconcileResult = reconcile(reused.nodes, changed_specs, &next_instance_id); + expect(state, inserted.inserted == 1 && reused.reused == 1 && + inserted.nodes.data[0].instance_id == reused.nodes.data[0].instance_id, + "same-key Canvas frames retain the same RenderNode instance"); + expect(state, updated.updated == 1 && updated.nodes.data[0].generation == 1 && + updated.nodes.data[0].instance_id == reused.nodes.data[0].instance_id, + "new immediate content updates rather than replaces the retained node"); + + first.display.ops.data[0].paint.red = first.display.ops.data[0].paint.red + 1; + expect(state, canvas_replay_hash(&first) == 0 && + first.last_error == CanvasError.content_mutated, + "out-of-contract command mutation invalidates replay evidence"); + first.display.ops.data[0].paint.red = first.display.ops.data[0].paint.red - 1; + expect(state, canvas_verify(&first) == CanvasError.none, + "restoring immutable command bytes restores Canvas verification"); + + reconcile_free(&updated); reconcile_free(&reused); reconcile_free(&inserted); + free[ViewSpec](&changed_specs); free[ViewSpec](&first_specs); + free[RenderNode](&empty); + _ = canvas_free(&other_generation); _ = canvas_free(&changed); + _ = canvas_free(&repeated); _ = canvas_free(&first); +} + +fn contribution_semantics_and_hit_contract(state: *TestState) void { + let spec: CanvasSpec = canvas_spec(node_key_scoped(200, 3), + rect(0, 0, 200 * unit_scale(), 100 * unit_scale()), + CanvasSemanticsPolicy.named_group, "Assembly viewport"); + spec.description = "Direct manipulation surface"; + spec.has_clip = 1; + spec.clip = rect(10 * unit_scale(), 10 * unit_scale(), + 180 * unit_scale(), 80 * unit_scale()); + spec.transform.translate_x = 100 * unit_scale(); + spec.transform.translate_y = 50 * unit_scale(); + spec.hit_testable = 1; + spec.focusable = 1; + spec.focus_order = 4; + spec.z_order = 9; + let canvas: Canvas = canvas_make(spec, canvas_limits_default()); + let fill: DisplayOp = canvas_op(canvas, DisplayOpKind.fill_rect); + fill.bounds = rect(0, 0, 200 * unit_scale(), 100 * unit_scale()); + fill.paint = paint_rgba16(5000, 10000, 20000, 65535); + expect(state, canvas_push(&canvas, fill) == CanvasError.none && + canvas_seal(&canvas) == CanvasError.none, + "valid immediate commands seal under one Canvas owner"); + + let destination: DisplayList = display_list_make(); + expect(state, canvas_contribute_display(&canvas, &destination) == CanvasError.none && + destination.ops.len == 5, + "Canvas contributes one balanced transform and clip envelope"); + expect(state, destination.ops.data[0].kind == DisplayOpKind.save && + destination.ops.data[1].kind == DisplayOpKind.concat_transform && + destination.ops.data[2].kind == DisplayOpKind.clip_rect && + destination.ops.data[3].kind == DisplayOpKind.fill_rect && + destination.ops.data[4].kind == DisplayOpKind.restore, + "display contribution preserves explicit save transform clip content restore order"); + let owners_match: i32 = 1; + let i: i32 = 0; + while (i < destination.ops.len) { + if (node_key_equal(destination.ops.data[i].owner, canvas.key) == 0) { + owners_match = 0; + } + i = i + 1; + } + expect(state, owners_match == 1 && + destination.ops.data[2].bounds.x == 10 * unit_scale(), + "every contributed operation retains the stable Canvas key and local clip"); + expect(state, display_list_seal(&destination) == DisplayError.none && + display_list_verify(destination) == DisplayError.none, + "contributed commands remain a valid immutable parent DisplayList"); + + let semantics: SemanticsTree = semantics_tree_make(); + expect(state, canvas_contribute_semantics(&canvas, &semantics) == CanvasError.none && + semantics.nodes.len == 1, + "named Canvas contributes one semantic node"); + let semantic: SemanticsNode = semantics.nodes.data[0]; + expect(state, semantic.role == SemanticRole.group && + semantic_text_equal(semantic.name, "Assembly viewport") == 1 && + semantic_text_equal(semantic.description, "Direct manipulation surface") == 1, + "group policy preserves owned accessible name and description"); + expect(state, semantic.bounds.x == 110 * unit_scale() && + semantic.bounds.y == 60 * unit_scale() && + semantic.bounds.width == 180 * unit_scale() && + semantic.bounds.height == 80 * unit_scale(), + "semantic bounds are the transformed effective Canvas clip"); + expect(state, semantic.focus_order == 4 && + semantic_actions_has(semantic.action_mask, SemanticAction.focus) == 1, + "focusable Canvas exposes the same explicit focus policy to semantics"); + + let hits: HitTree = hit_tree_make(); + expect(state, canvas_contribute_hit(&canvas, &hits) == CanvasError.none && + hits.nodes.len == 1 && hits.nodes.data[0].z_order == 9, + "interactive Canvas contributes the same retained identity to HitTree"); + let world_hit: HitResult = hit_test(hits, + 120 * unit_scale(), 70 * unit_scale()); + let direct_hit: CanvasLocalResult = canvas_local_from_world(&canvas, + 120 * unit_scale(), 70 * unit_scale()); + expect(state, world_hit.found == 1 && direct_hit.found == 1 && + world_hit.local_x == 20 * unit_scale() && + direct_hit.x == world_hit.local_x && direct_hit.y == world_hit.local_y, + "HitTree and direct Canvas conversion agree on exact local coordinates"); + let clipped: CanvasLocalResult = canvas_local_from_world(&canvas, + 105 * unit_scale(), 55 * unit_scale()); + expect(state, clipped.error == CanvasError.none && clipped.found == 0, + "local coordinate conversion rejects points outside the declared clip"); + + let rotated_spec: CanvasSpec = canvas_spec(node_key(201), + rect(0, 0, 64, 32), CanvasSemanticsPolicy.named_image, "Rotated preview"); + rotated_spec.transform.scale_x = 0; + rotated_spec.transform.skew_x = 0 - unit_scale(); + rotated_spec.transform.skew_y = unit_scale(); + rotated_spec.transform.scale_y = 0; + rotated_spec.transform.translate_x = 128; + rotated_spec.hit_testable = 1; + let rotated: Canvas = canvas_make(rotated_spec, canvas_limits_default()); + _ = canvas_seal(&rotated); + let rotated_tree: SemanticsTree = semantics_tree_make(); + _ = canvas_contribute_semantics(&rotated, &rotated_tree); + let rotated_local: CanvasLocalResult = canvas_local_from_world(&rotated, 112, 32); + expect(state, rotated_tree.nodes.len == 1 && + rotated_tree.nodes.data[0].bounds.x == 96 && + rotated_tree.nodes.data[0].bounds.width == 32 && + rotated_tree.nodes.data[0].bounds.height == 64, + "rotated Canvas exposes a deterministic transformed semantic AABB"); + expect(state, rotated_local.found == 1 && rotated_local.x == 32 && + rotated_local.y == 16, + "rotated affine inversion preserves exact Canvas-local coordinates"); + + semantics_tree_free(&rotated_tree); _ = canvas_free(&rotated); + hit_tree_free(&hits); semantics_tree_free(&semantics); + display_list_free(&destination); _ = canvas_free(&canvas); +} + +fn semantic_policy_contract(state: *TestState) void { + let image_spec: CanvasSpec = canvas_spec(node_key(301), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.named_image, "Rendered preview"); + let image_canvas: Canvas = canvas_make(image_spec, canvas_limits_default()); + _ = canvas_seal(&image_canvas); + let tree: SemanticsTree = semantics_tree_make(); + _ = canvas_contribute_semantics(&image_canvas, &tree); + expect(state, tree.nodes.len == 1 && tree.nodes.data[0].role == SemanticRole.image && + tree.nodes.data[0].hidden == 0, + "named-image policy emits visible image semantics"); + + let decorative_spec: CanvasSpec = canvas_spec(node_key(302), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.decorative, ""); + let decorative: Canvas = canvas_make(decorative_spec, canvas_limits_default()); + _ = canvas_seal(&decorative); + expect(state, canvas_contribute_semantics(&decorative, &tree) == CanvasError.none && + tree.nodes.len == 2 && tree.nodes.data[1].role == SemanticRole.group && + tree.nodes.data[1].hidden == 1 && tree.nodes.data[1].name.len == 0, + "decorative policy remains explicitly represented and hidden"); + let hits: HitTree = hit_tree_make(); + expect(state, canvas_contribute_hit(&decorative, &hits) == + CanvasError.not_interactive && hits.nodes.len == 0, + "decorative Canvas cannot silently become an interactive pixel target"); + + let disabled_spec: CanvasSpec = canvas_spec(node_key(303), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.named_group, + "Unavailable viewport"); + disabled_spec.hit_testable = 1; + disabled_spec.focusable = 1; + disabled_spec.focus_order = 7; + disabled_spec.enabled = 0; + let disabled: Canvas = canvas_make(disabled_spec, canvas_limits_default()); + _ = canvas_seal(&disabled); + expect(state, canvas_contribute_semantics(&disabled, &tree) == CanvasError.none && + tree.nodes.data[2].disabled == 1 && tree.nodes.data[2].focus_order == 7 && + tree.nodes.data[2].action_mask == 0, + "disabled Canvas remains ordered and discoverable without a focus action"); + expect(state, canvas_contribute_hit(&disabled, &hits) == CanvasError.none && + hits.nodes.len == 1 && hits.nodes.data[0].enabled == 0 && + hit_test(hits, 10, 10).found == 0, + "disabled Canvas cannot win pointer hit testing"); + + hit_tree_free(&hits); semantics_tree_free(&tree); + _ = canvas_free(&disabled); _ = canvas_free(&decorative); + _ = canvas_free(&image_canvas); +} + +fn resource_ownership_contract(state: *TestState) void { + let pixels: ArrayList[u8] = make[u8](4); + push[u8](&pixels, 10); push[u8](&pixels, 20); + push[u8](&pixels, 30); push[u8](&pixels, 255); + let spec: CanvasSpec = canvas_spec(node_key_scoped(400, 2), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.named_image, "One pixel"); + let canvas: Canvas = canvas_make(spec, canvas_limits_default()); + let resource: CanvasResourceSpec = canvas_resource_spec(7, + RenderResourceKind.image, pixels.data[0..pixels.len], + image_resource_format_tag()); + resource.width = 1; + resource.height = 1; + resource.color_space = RenderColorSpace.srgb; + expect(state, canvas_add_resource(&canvas, resource) == CanvasError.none, + "Canvas copies a namespaced local resource into owned storage"); + let global_id: i64 = canvas_resource_id(canvas.key, 7); + pixels.data[0] = 99; + expect(state, canvas.display.resources.resources.data[0].id == global_id && + canvas.display.resources.resources.data[0].payload.data[0] == 10, + "caller byte mutation cannot alter Canvas-owned resource bytes"); + expect(state, global_id == canvas_resource_id(canvas.key, 7) && + global_id != canvas_resource_id(node_key_scoped(401, 2), 7) && + global_id != canvas_resource_id(canvas.key, 8), + "resource identity is stable and scoped by Canvas key and local ID"); + let draw: DisplayOp = canvas_resource_op(canvas, DisplayOpKind.draw_image, 7); + draw.bounds = rect(0, 0, 64, 64); + expect(state, canvas_push(&canvas, draw) == CanvasError.none && + canvas_seal(&canvas) == CanvasError.none, + "namespaced resource references seal against their exact kind"); + + let destination: DisplayList = display_list_make(); + expect(state, canvas_contribute_display(&canvas, &destination) == CanvasError.none && + destination.resources.resources.len == 1 && + destination.resources.resources.data[0].payload.data[0] == 10, + "display contribution takes an independent copy of Canvas resources"); + expect(state, canvas_free(&canvas) == CanvasError.none && + canvas_free(&canvas) == CanvasError.released, + "Canvas cleanup is explicit and a repeated release fails without double-freeing"); + expect(state, display_list_seal(&destination) == DisplayError.none && + display_list_verify(destination) == DisplayError.none && + destination.resources.resources.data[0].payload.data[0] == 10, + "parent DisplayList remains valid after Canvas-owned resources are released"); + display_list_free(&destination); free[u8](&pixels); +} + +fn invalid_construction_contract(state: *TestState) void { + let empty_name: CanvasSpec = canvas_spec(node_key(500), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.named_group, ""); + let empty: Canvas = canvas_make(empty_name, canvas_limits_default()); + expect(state, empty.last_error == CanvasError.invalid_semantics, + "named Canvas requires an explicit accessible name"); + + let decorative_interactive: CanvasSpec = canvas_spec(node_key(501), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.decorative, ""); + decorative_interactive.hit_testable = 1; + let bad_decorative: Canvas = canvas_make(decorative_interactive, + canvas_limits_default()); + expect(state, bad_decorative.last_error == CanvasError.invalid_semantics, + "decorative Canvas fails closed when configured as interactive"); + + let clipped_spec: CanvasSpec = canvas_spec(node_key(502), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.named_image, "Clip"); + clipped_spec.has_clip = 1; + clipped_spec.clip = rect(32, 32, 64, 64); + let clipped: Canvas = canvas_make(clipped_spec, canvas_limits_default()); + expect(state, clipped.last_error == CanvasError.invalid_clip, + "clip geometry cannot escape Canvas bounds"); + + let singular_spec: CanvasSpec = canvas_spec(node_key(503), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.named_image, "Transform"); + singular_spec.transform.scale_x = 0; + let singular: Canvas = canvas_make(singular_spec, canvas_limits_default()); + expect(state, singular.last_error == CanvasError.invalid_transform, + "singular Canvas transforms fail before display or hit-tree mutation"); + + let invalid_key_spec: CanvasSpec = canvas_spec(node_key(0), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.named_group, "Key"); + let invalid_key: Canvas = canvas_make(invalid_key_spec, canvas_limits_default()); + expect(state, invalid_key.last_error == CanvasError.invalid_key, + "reserved and nonpositive Canvas identities fail visibly"); + + let oversized_name: ArrayList[u8] = make[u8](4100); + let name_index: i32 = 0; + while (name_index < 4097) { + push[u8](&oversized_name, 65); + name_index = name_index + 1; + } + let oversized_spec: CanvasSpec = canvas_spec(node_key(504), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.named_group, + oversized_name.data[0..oversized_name.len]); + let oversized: Canvas = canvas_make(oversized_spec, canvas_limits_default()); + expect(state, oversized.last_error == CanvasError.invalid_semantics && + oversized.name.len == 0, + "oversized borrowed semantic text fails before Canvas takes a proportional copy"); + + let invalid_limits: CanvasLimits = canvas_limits_default(); + invalid_limits.max_resources = 0; + let limited: Canvas = canvas_make(canvas_spec(node_key(505), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.named_group, "Limits"), + invalid_limits); + expect(state, limited.last_error == CanvasError.invalid_limits && + limited.name.len == 0, + "invalid allocation limits fail before borrowed metadata is copied"); + + let malformed_name: ArrayList[u8] = make[u8](2); + push[u8](&malformed_name, 194); push[u8](&malformed_name, 65); + let malformed: Canvas = canvas_make(canvas_spec(node_key(506), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.named_group, + malformed_name.data[0..malformed_name.len]), canvas_limits_default()); + expect(state, malformed.last_error == CanvasError.invalid_semantics && + malformed.name.len == 0, + "malformed UTF-8 metadata fails before Canvas takes ownership"); + + _ = canvas_free(&malformed); free[u8](&malformed_name); + _ = canvas_free(&limited); _ = canvas_free(&oversized); + free[u8](&oversized_name); + _ = canvas_free(&invalid_key); _ = canvas_free(&singular); + _ = canvas_free(&clipped); _ = canvas_free(&bad_decorative); + _ = canvas_free(&empty); +} + +fn operation_and_resource_limits_contract(state: *TestState) void { + let limits: CanvasLimits = canvas_limits_default(); + limits.max_operations = 1; + let spec: CanvasSpec = canvas_spec(node_key(600), rect(0, 0, 64, 64), + CanvasSemanticsPolicy.named_group, "Limited"); + let canvas: Canvas = canvas_make(spec, limits); + let first: DisplayOp = canvas_op(canvas, DisplayOpKind.fill_rect); + first.bounds = rect(0, 0, 64, 64); + let second: DisplayOp = first; + expect(state, canvas_push(&canvas, first) == CanvasError.none && + canvas_push(&canvas, second) == CanvasError.too_many_operations && + canvas.display.ops.len == 1, + "operation ceiling rejects overflow before command mutation"); + + let owner_limits: CanvasLimits = canvas_limits_default(); + let owner_canvas: Canvas = canvas_make(canvas_spec(node_key(601), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.named_group, "Owner"), owner_limits); + let foreign: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(999)); + foreign.bounds = rect(0, 0, 64, 64); + expect(state, canvas_push(&owner_canvas, foreign) == CanvasError.invalid_owner && + owner_canvas.display.ops.len == 0, + "commands owned by another retained node fail before mutation"); + + let byte_limits: CanvasLimits = canvas_limits_default(); + byte_limits.max_resource_bytes = 2; + byte_limits.max_total_resource_bytes = 2; + let byte_canvas: Canvas = canvas_make(canvas_spec(node_key(602), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.named_image, "Bytes"), byte_limits); + expect(state, canvas_add_resource(&byte_canvas, canvas_resource_spec(1, + RenderResourceKind.effect, "123", 1)) == CanvasError.resource_failure && + byte_canvas.resource_error == RenderResourceError.payload_limit && + byte_canvas.display.resources.resources.len == 0, + "resource byte ceiling exposes the underlying failure without taking ownership"); + + _ = canvas_free(&byte_canvas); _ = canvas_free(&owner_canvas); + _ = canvas_free(&canvas); +} + +fn transactional_destination_contract(state: *TestState) void { + let pixels: []u8 = "RGBA"; + let spec: CanvasSpec = canvas_spec(node_key(700), rect(0, 0, 64, 64), + CanvasSemanticsPolicy.named_image, "Collision"); + let canvas: Canvas = canvas_make(spec, canvas_limits_default()); + let resource: CanvasResourceSpec = canvas_resource_spec(1, + RenderResourceKind.image, pixels, image_resource_format_tag()); + resource.width = 1; resource.height = 1; + resource.color_space = RenderColorSpace.srgb; + _ = canvas_add_resource(&canvas, resource); + let image: DisplayOp = canvas_resource_op(canvas, DisplayOpKind.draw_image, 1); + image.bounds = rect(0, 0, 64, 64); + _ = canvas_push(&canvas, image); _ = canvas_seal(&canvas); + + let collision: DisplayList = display_list_make(); + let existing: RenderResourceSpec = render_resource_spec( + canvas_resource_id(canvas.key, 1), RenderResourceKind.image, + pixels, image_resource_format_tag()); + existing.width = 1; existing.height = 1; + existing.color_space = RenderColorSpace.srgb; + _ = display_list_add_resource(&collision, existing); + let old_hash: i64 = collision.content_hash; + let old_revision: i64 = collision.revision; + expect(state, canvas_contribute_display(&canvas, &collision) == + CanvasError.resource_collision && collision.ops.len == 0 && + collision.resources.resources.len == 1 && + collision.content_hash == old_hash && collision.revision == old_revision, + "resource collision preflight leaves the destination byte-for-byte unchanged"); + + let unsealed: Canvas = canvas_make(canvas_spec(node_key(701), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.named_group, "Unsealed"), + canvas_limits_default()); + let untouched: DisplayList = display_list_make(); + expect(state, canvas_contribute_display(&unsealed, &untouched) == + CanvasError.not_sealed && untouched.ops.len == 0 && + untouched.resources.resources.len == 0, + "unsealed Canvas cannot contribute partial display state"); + + let tight_limits: CanvasLimits = canvas_limits_default(); + tight_limits.max_operations = 0; + tight_limits.max_destination_operations = 4; + let tight: Canvas = canvas_make(canvas_spec(node_key(702), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.named_group, "Tight"), + tight_limits); + _ = canvas_seal(&tight); + let crowded: DisplayList = display_list_make(); + let background: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(99)); + background.bounds = rect(0, 0, 64, 64); + _ = display_list_push(&crowded, background); + let crowded_hash: i64 = crowded.content_hash; + expect(state, canvas_contribute_display(&tight, &crowded) == + CanvasError.destination_limit && crowded.ops.len == 1 && + crowded.content_hash == crowded_hash, + "destination operation ceiling fails before envelope mutation"); + + display_list_free(&crowded); _ = canvas_free(&tight); + display_list_free(&untouched); _ = canvas_free(&unsealed); + display_list_free(&collision); _ = canvas_free(&canvas); +} + +fn tree_failure_and_cleanup_contract(state: *TestState) void { + let spec: CanvasSpec = canvas_spec(node_key(800), rect(0, 0, 64, 64), + CanvasSemanticsPolicy.named_group, "Orphan"); + spec.semantic_parent = node_key(899); + spec.hit_parent = node_key(899); + spec.hit_testable = 1; + let canvas: Canvas = canvas_make(spec, canvas_limits_default()); + _ = canvas_seal(&canvas); + let semantics: SemanticsTree = semantics_tree_make(); + let hits: HitTree = hit_tree_make(); + expect(state, canvas_contribute_semantics(&canvas, &semantics) == + CanvasError.semantics_failure && + canvas.semantics_error == SemanticsError.missing_parent && + semantics.nodes.len == 0, + "semantic parent failure is surfaced without partial tree mutation"); + expect(state, canvas_contribute_hit(&canvas, &hits) == CanvasError.hit_failure && + canvas.hit_error == HitTreeError.missing_parent && hits.nodes.len == 0, + "hit parent failure is surfaced without partial tree mutation"); + hit_tree_free(&hits); semantics_tree_free(&semantics); _ = canvas_free(&canvas); + + let cleanup_ok: i32 = 1; + let i: i32 = 0; + while (i < 256) { + let item: Canvas = canvas_make(canvas_spec(node_key(1000 + i as i64), + rect(0, 0, 64, 64), CanvasSemanticsPolicy.decorative, ""), + canvas_limits_default()); + if (canvas_seal(&item) != CanvasError.none || + canvas_free(&item) != CanvasError.none) { cleanup_ok = 0; } + i = i + 1; + } + expect(state, cleanup_ok == 1, + "256 create seal cleanup cycles preserve explicit ownership invariants"); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + retained_and_replay_contract(&state); + contribution_semantics_and_hit_contract(&state); + semantic_policy_contract(&state); + resource_ownership_contract(&state); + invalid_construction_contract(&state); + operation_and_resource_limits_contract(&state); + transactional_destination_contract(&state); + tree_failure_and_cleanup_contract(&state); + let passed: []u8 = _zag_i64_to_str(state.passed as i64); + let failed: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("Canvas contract: pass="); _zag_print(passed); + _zag_print(" fail="); _zag_println(failed); + _zag_str_free(passed); _zag_str_free(failed); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tests/design_tokens_contract.zag b/tests/design_tokens_contract.zag index 3bffa93..945eedd 100644 --- a/tests/design_tokens_contract.zag +++ b/tests/design_tokens_contract.zag @@ -26,7 +26,7 @@ fn paint_equal(left: Paint, right: Paint) i32 { fn main() i32 { let state: TestState = TestState{ .passed = 0, .failed = 0 }; - expect(&state, semantic_color_token_count() == 30 && + expect(&state, semantic_color_token_count() == 31 && bytes_equal(semantic_color(SemanticColorToken.accent).id, "color.accent") != 0, "semantic colors expose stable inspectable token IDs"); expect(&state, paint_equal(semantic_color_paint(SemanticColorToken.accent), diff --git a/tests/semantics_contract.zag b/tests/semantics_contract.zag index a8b6134..9f4092b 100644 --- a/tests/semantics_contract.zag +++ b/tests/semantics_contract.zag @@ -128,6 +128,24 @@ fn rejection_contract(state: *TestState) void { bad_state.selected = 2; expect(state, semantics_add(&tree, bad_state) == SemanticsError.invalid_state, "noncanonical semantic flags fail instead of leaking ambiguous state"); + + let malformed_bytes: ArrayList[u8] = make[u8](2); + push[u8](&malformed_bytes, 194); push[u8](&malformed_bytes, 65); + let malformed_text: SemanticsSpec = semantics_spec(node_key(20), root_id, + SemanticRole.text, malformed_bytes.data[0..malformed_bytes.len]); + expect(state, semantics_add(&tree, malformed_text) == SemanticsError.invalid_text && + tree.nodes.len == 2, + "malformed UTF-8 cannot enter owned accessibility text"); + free[u8](&malformed_bytes); + + let nul_bytes: ArrayList[u8] = make[u8](2); + push[u8](&nul_bytes, 65); push[u8](&nul_bytes, 0); + let nul_text: SemanticsSpec = semantics_spec(node_key(21), root_id, + SemanticRole.text, nul_bytes.data[0..nul_bytes.len]); + expect(state, semantics_add(&tree, nul_text) == SemanticsError.invalid_text && + tree.nodes.len == 2, + "embedded NUL cannot truncate platform accessibility adapters"); + free[u8](&nul_bytes); semantics_tree_free(&tree); } diff --git a/tests/unicode_contract.zag b/tests/unicode_contract.zag index e54f09e..6ab1c4e 100644 --- a/tests/unicode_contract.zag +++ b/tests/unicode_contract.zag @@ -92,10 +92,77 @@ fn invalid_contract(state: *TestState) void { utf8_decode_free(&range); free[u8](&range_bytes); } +fn allocation_free_validation_contract(state: *TestState) void { + let valid: Utf8ValidationResult = utf8_validate("Zagkit é中😀"); + expect(state, valid.error == Utf8Error.none && valid.scalar_count == 10, + "allocation-free validation counts the same canonical Unicode scalars"); + + let malformed: ArrayList[u8] = make[u8](2); + push[u8](&malformed, 226); push[u8](&malformed, 65); + let invalid: Utf8ValidationResult = utf8_validate( + malformed.data[0..malformed.len]); + expect(state, invalid.error == Utf8Error.truncated && invalid.error_offset == 0, + "allocation-free validation preserves exact malformed-sequence evidence"); + free[u8](&malformed); + + let one: ArrayList[u8] = make[u8](1); + push[u8](&one, 0); + let matching: i32 = 1; + let byte: i32 = 0; + while (byte < 256) { + one.data[0] = byte as u8; + let decoded: Utf8DecodeResult = utf8_decode(one.data[0..one.len]); + let checked: Utf8ValidationResult = utf8_validate(one.data[0..one.len]); + if (decoded.error != checked.error || + decoded.error_offset != checked.error_offset || + (decoded.error == Utf8Error.none && + decoded.scalars.len as i64 != checked.scalar_count)) { + matching = 0; + } + utf8_decode_free(&decoded); + byte = byte + 1; + } + expect(state, matching == 1, + "all 256 one-byte inputs agree between validating and decoding paths"); + free[u8](&one); + + let arbitrary: ArrayList[u8] = make[u8](8); + let seed: i64 = 173; + let case_index: i32 = 0; + let differential: i32 = 1; + while (case_index < 4096) { + seed = (seed * 1103515245 + 12345) & 2147483647; + let length: i32 = (seed % 8) as i32 + 1; + arbitrary.len = 0; + let item_index: i32 = 0; + while (item_index < length) { + seed = (seed * 1103515245 + 12345) & 2147483647; + push[u8](&arbitrary, (seed & 255) as u8); + item_index = item_index + 1; + } + let decoded: Utf8DecodeResult = utf8_decode( + arbitrary.data[0..arbitrary.len]); + let checked: Utf8ValidationResult = utf8_validate( + arbitrary.data[0..arbitrary.len]); + if (decoded.error != checked.error || + decoded.error_offset != checked.error_offset || + (decoded.error == Utf8Error.none && + decoded.scalars.len as i64 != checked.scalar_count)) { + differential = 0; + } + utf8_decode_free(&decoded); + case_index = case_index + 1; + } + expect(state, differential == 1, + "4096 arbitrary byte strings preserve decoder-validator differential truth"); + free[u8](&arbitrary); +} + fn main() i32 { let state: TestState = TestState{ .passed = 0, .failed = 0 }; valid_contract(&state); invalid_contract(&state); + allocation_free_validation_contract(&state); let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); let failed_text: []u8 = _zag_i64_to_str(state.failed as i64); _zag_print("Unicode contract: pass="); _zag_print(passed_text); diff --git a/tools/check-contracts.sh b/tools/check-contracts.sh index 5e86a69..ed1331e 100755 --- a/tools/check-contracts.sh +++ b/tools/check-contracts.sh @@ -160,17 +160,29 @@ for id in \ done jq -e ' + (.schema_version == 2) and + (.status_vocabulary == ["planned", "implementing", "experimental", "conformant"]) and (.visual_direction_gate.state == "blocked") and (.components | length >= 40) and ((.components | map(.id) | unique | length) == (.components | length)) and all(.components[]; - (.status == "planned") and + (.status | IN("planned", "implementing", "experimental", "conformant")) and + (if .status == "planned" then true else + ((.source | type == "string" and length > 0) and + (.evidence | type == "string" and length > 0) and + (.documentation | type == "string" and length > 0)) + end) and (.milestone >= 2 and .milestone <= 4) and (.semantic_roles | type == "array" and length > 0) and (.inputs | type == "array") and (.adaptive | type == "array" and length > 0) - ) + ) and + all(.components[]; .status != "experimental" and .status != "conformant") ' contracts/components.json >/dev/null || fail "component inventory or visual production gate is incomplete" +for component_path in $(jq -r '.components[] | select(.status != "planned") | + .source, .evidence, .documentation' contracts/components.json); do + require_file "$component_path" +done jq -e ' (.result_state == "specifications-only") and diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 852922f..7b4bc43 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -36,6 +36,10 @@ cd "$root" "$tmp/flex-adaptive-contract" "$znc" tests/design_tokens_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/design-tokens-contract" "$tmp/design-tokens-contract" +"$znc" tests/button_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/button-contract" +"$tmp/button-contract" +"$znc" tests/canvas_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/canvas-contract" +"$tmp/canvas-contract" "$znc" tests/measure_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/measure-contract" "$tmp/measure-contract" "$znc" tests/overlay_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/overlay-contract" @@ -94,4 +98,4 @@ printf 'Linux preview CPU snapshot contract: pass=2 fail=0\n' "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (backend truth, state, reconciliation, intrinsic measurement, constraints, Flex, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, Unicode grapheme segmentation, positioned glyph-run transport, collection semantics, Talkback, native-preview semantics, owned render resources, canonical paths and images, bounded PNG decode, display lists, analytic rounded geometry, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion)\n' +printf 'headless test: PASS (backend truth, state, reconciliation, intrinsic measurement, constraints, Flex, shared Button state/render/hit/semantics/Talkback, retained Canvas immediate drawing, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, Unicode grapheme segmentation, positioned glyph-run transport, collection semantics, Talkback, native-preview semantics, owned render resources, canonical paths and images, bounded PNG decode, display lists, analytic rounded geometry, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion)\n' From 9e1d17742871488018d6a2e68474fc0420cf802e Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 04:09:41 -0700 Subject: [PATCH 086/103] Add executable Button conformance gallery --- README.md | 14 +- contracts/components.json | 2 +- docs/components/button-gallery.md | 71 ++ docs/components/button.md | 4 + docs/evidence/button-gallery-2026-08-09.md | 45 ++ src/showcase/button_gallery.zag | 772 +++++++++++++++++++++ tests/button_gallery_contract.zag | 265 +++++++ tools/render-button-gallery.sh | 25 + tools/render-button-gallery.zag | 42 ++ tools/test-button-gallery.sh | 22 + tools/test-headless.sh | 13 +- 11 files changed, 1272 insertions(+), 3 deletions(-) create mode 100644 docs/components/button-gallery.md create mode 100644 docs/evidence/button-gallery-2026-08-09.md create mode 100644 src/showcase/button_gallery.zag create mode 100644 tests/button_gallery_contract.zag create mode 100755 tools/render-button-gallery.sh create mode 100644 tools/render-button-gallery.zag create mode 100755 tools/test-button-gallery.sh diff --git a/README.md b/README.md index 7dd0a01..8f0080c 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ is normative in [DEPENDENCIES.md](DEPENDENCIES.md). | Compiler dependency | pinned, prerequisites incomplete | [toolchain lock](contracts/toolchain.json) | | Platform shells | unavailable | [support matrix](SUPPORT.md) | | Headless core | experimental state, reconciliation, intrinsic measurement, geometry, Flex, shared Button composition, retained Canvas drawing, Grid, Overlay, scroll, virtual collections, collection semantics, Talkback, canonical paths and images, display lists, CPU raster, input, replay, and motion | [headless test](tools/test-headless.sh) | -| Components and visual language | Button and Canvas implementing against headless contracts; direction review and all other component families remain incomplete | [component inventory](contracts/components.json) | +| Components and visual language | Button and Canvas implementing against headless contracts; Button has an executable state/variant gallery; direction review and all other component families remain incomplete | [component inventory](contracts/components.json) | | Flex and Zagkit Talkback | Flex foundation and in-process ID-first Talkback dispatch executing; native transport remains unavailable | [Talkback contract](docs/automation/talkback.md) | | Benchmarks | scene specifications only, no results | [benchmark contract](benchmarks/README.md) | @@ -58,6 +58,18 @@ The gate validates the release identity, exact Zag revision, required platform families, backend truth states, upstream prerequisite ledger, component inventory, benchmark scene coverage, and the 1.0 block. +Run and render the experimental Button conformance gallery with: + +```sh +./tools/test-button-gallery.sh +./tools/render-button-gallery.sh +``` + +The gallery uses the real retained Button, OpenType glyph paths, semantic +tokens, Flex spacing, shared hit and semantics identity, and ID-first Zagkit +Talkback. It is explicitly not the still-unselected production visual +direction; see the [gallery contract](docs/components/button-gallery.md). + The compiled headless contract currently provides revisioned `State`, action-producing `Binding`, inherited integer environment values, exact per-node state-read records, fail-visible keyed reconciliation, deterministic diff --git a/contracts/components.json b/contracts/components.json index b3464c9..8e590e7 100644 --- a/contracts/components.json +++ b/contracts/components.json @@ -31,7 +31,7 @@ {"id":"shadow","family":"effects","name":"Shadow","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["density","contrast","color_space"]}, {"id":"light","family":"effects","name":"Light","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["contrast","color_space","reduced_transparency"]}, {"id":"divider","family":"content","name":"Divider","milestone":2,"status":"planned","semantic_roles":["separator"],"inputs":[],"adaptive":["density","contrast","direction"]}, - {"id":"button","family":"actions","name":"Button","milestone":2,"status":"implementing","semantic_roles":["button"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","contrast","reduced_motion"],"source":"src/components/button.zag","evidence":"tests/button_contract.zag","documentation":"docs/components/button.md"}, + {"id":"button","family":"actions","name":"Button","milestone":2,"status":"implementing","semantic_roles":["button"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","contrast","reduced_motion"],"source":"src/components/button.zag","evidence":"tests/button_contract.zag","documentation":"docs/components/button.md","gallery_source":"src/showcase/button_gallery.zag","gallery_evidence":"tests/button_gallery_contract.zag","gallery_documentation":"docs/components/button-gallery.md"}, {"id":"toggle","family":"actions","name":"Toggle","milestone":2,"status":"planned","semantic_roles":["switch"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","contrast","reduced_motion"]}, {"id":"checkbox","family":"actions","name":"Checkbox","milestone":2,"status":"planned","semantic_roles":["checkbox"],"inputs":["pointer","keyboard","touch"],"adaptive":["density","platform","contrast"]}, {"id":"radio","family":"actions","name":"RadioGroup","milestone":2,"status":"planned","semantic_roles":["radio","radiogroup"],"inputs":["pointer","keyboard","touch"],"adaptive":["density","platform","direction"]}, diff --git a/docs/components/button-gallery.md b/docs/components/button-gallery.md new file mode 100644 index 0000000..b16f0fd --- /dev/null +++ b/docs/components/button-gallery.md @@ -0,0 +1,71 @@ +# Experimental Button conformance gallery + +The Button gallery is an executable component contract, not a proposed 1.0 +theme. RFC 0007 remains open, so the scene labels itself `EXPERIMENTAL` in both +visible text and semantics. Its purpose is to prove that the current Button can +carry coherent rules before visual-direction selection. + +## What the scene proves + +The first matrix repeats the same secondary `Button` and the same `Run build` +label across rest, hover, focus, pressed, selected, disabled, loading, and error +states. The second matrix holds state at rest and varies primary, secondary, +quiet, and destructive roles. Every example calls `button_emit`; there are no +hand-painted Button lookalikes. + +All twelve controls contribute to one `DisplayList`, one `HitTree`, and one +`SemanticsTree`. Their caller-owned labels are actual OpenType outlines routed +through Zagkit's nominal glyph run, canonical glyph path, retained path +resource, and deterministic CPU rasterizer. Loading deliberately substitutes +the component progress indicator, so eleven visible Button labels plus every +heading and state caption become glyph-path resources. Placeholder bars are +not accepted as gallery text. + +The composition demonstrates the named type ramp and three semantic elevation +tiers. Button material comes exclusively from semantic color, radius, spacing, +and elevation tokens. State is never color-only: focus has a ring, pressed +changes elevation, selected is exposed in semantics, and disabled/loading make +actions unavailable. Headings establish hierarchy; the state and variant +sections are not competing navigation patterns. + +## Stable Talkback IDs + +These mappings are public experimental fixtures and must remain stable while +the gallery contract is used for regression testing: + +| Fixture | `NodeKey.value` | +|---|---:| +| State: rest through error | `4100` through `4107` | +| Variant: primary | `4200` | +| Variant: secondary | `4201` | +| Variant: quiet | `4202` | +| Variant: destructive | `4203` | +| Gallery title | `4300` | +| State section heading | `4301` | +| Variant section heading | `4302` | +| Experimental status | `4303` | + +Talkback click and query operations target these semantic IDs. Disabled and +loading clicks fail closed as `action_unavailable`; the contract explicitly +checks that it does not depend on pixel fallback. + +## Run the evidence + +```sh +./tools/test-button-gallery.sh +./tools/render-button-gallery.sh +``` + +Both scripts resolve Noto Sans through Fontconfig by default or accept an +explicit `.ttf` path as their first argument. The renderer accepts an optional +PNG path as its second argument. Compilation uses strict analysis with the +canonical Zag compiler and the renderer prints retained-evidence and CPU-pixel +hashes. + +## Honest limits + +This is a deterministic headless CPU artifact. It does not certify native +hover delivery, keyboard focus traversal, platform accessibility adapters, +GPU parity, animation, reduced motion, or the final visual direction. Text is +currently nominal left-to-right OpenType placement; full shaping, bidi, font +fallback, variable/color fonts, and line layout remain separate roadmap gates. diff --git a/docs/components/button.md b/docs/components/button.md index 2f113ef..e68d6c9 100644 --- a/docs/components/button.md +++ b/docs/components/button.md @@ -97,3 +97,7 @@ The executable contract in `tests/button_contract.zag` covers measurement, state reduction, all eight showcase states, token mapping, immutable display verification, hit identity, semantic state, Talkback activation, fail-closed disabled behavior, and atomic rollback. + +The executable [Button conformance gallery](button-gallery.md) repeats this +same public component across every canonical state and semantic variant. It is +headless CPU evidence and does not promote Button beyond `implementing`. diff --git a/docs/evidence/button-gallery-2026-08-09.md b/docs/evidence/button-gallery-2026-08-09.md new file mode 100644 index 0000000..de9bb29 --- /dev/null +++ b/docs/evidence/button-gallery-2026-08-09.md @@ -0,0 +1,45 @@ +# Experimental Button gallery checkpoint — 2026-08-09 + +This checkpoint records executable component-system evidence, not acceptance +of RFC 0007 and not a conformant or production-ready Button claim. + +## Result + +- Focused strict contract: `Button gallery contract: pass=22 fail=0`. +- The focused contract passed twice with retained evidence hash + `-2919151821371210844` and deterministic CPU surface hash `1920018921`. +- The renderer produced `artifacts/evidence/button-gallery-experimental.png`, + a 1280 by 900 RGBA PNG with SHA-256 + `72ad2dec7484c34ec252aa64ad835fe966b5859c29c9fa43dbf7b9aca61032f2`. +- The gallery contract is part of `tools/test-headless.sh` and resolves an + explicit system font through Fontconfig rather than drawing placeholder text. + +## System proof + +The scene builds twelve controls through the public retained Button: the same +secondary variant in rest, hover, focus, pressed, selected, disabled, loading, +and error states, followed by primary, secondary, quiet, and destructive role +variants. Named Flex metrics drive repeated visual placement and the matching +semantic heading bounds. Semantic color, radius, typography, and elevation +tokens remain inspectable; the scene shows base, panel, and raised depth tiers. + +All visible labels are owned OpenType outline paths rendered by the CPU oracle. +The same stable `NodeKey` owns each Button's display operations, hit target, +semantics, focus order, and Talkback target. IDs `4100` through `4107` name the +state fixtures and `4200` through `4203` name the variants. Disabled and loading +clicks fail as `action_unavailable`; the gallery's Talkback proof keeps pixel +fallback disabled. + +Visual inspection of the final PNG confirmed the exterior focus ring, dropped +pressed elevation, selected treatment, recessed disabled control, loading +progress mark, explicit error label and outline, variant separation, type +hierarchy, and section hierarchy. This answers the state-matrix, token, +elevation, typography, and ambiguous-control concerns in the showcase review. + +## Honest limits + +The artifact is a deterministic headless CPU scene. It does not prove native +hover or keyboard delivery, animation, reduced motion, native accessibility, +GPU parity, international shaping, final contrast matrices, or a selected +liquid-glass direction. Button therefore remains `implementing`, and +`G4-GALLERY`, `G5-SHOWCASE-CONFORMANCE`, and `G5-LINUX-POLISH` remain open. diff --git a/src/showcase/button_gallery.zag b/src/showcase/button_gallery.zag new file mode 100644 index 0000000..9af5a7a --- /dev/null +++ b/src/showcase/button_gallery.zag @@ -0,0 +1,772 @@ +@import("../components/button.zag") +@import("../text/glyph_run.zag") + +// Experimental Button conformance gallery. The visual direction remains +// blocked on RFC 0007; this scene proves component contracts, not a 1.0 theme. + +enum ButtonGalleryError { + none, + invalid_font, + display_error, + button_error, + semantics_error, + text_shape_error, + text_path_error, + text_resource_error, +} + +struct ButtonGalleryTextResult { + error: ButtonGalleryError, + glyph_error: GlyphRunError, + glyph_count: i32, + path_count: i32, + advance: i64, +} + +struct ButtonGalleryLayoutMetrics { + canvas_inset: i64, + panel_padding: i64, + card_gap: i64, + card_padding: i64, + state_row_gap: i64, + heading_gap: i64, + control_height: i64, + variant_control_height: i64, + card_width: i64, + column_stride: i64, + header_top: i64, + header_height: i64, + state_panel_top: i64, + state_panel_height: i64, + state_card_top: i64, + state_card_height: i64, + state_control_offset: i64, + variant_panel_top: i64, + variant_panel_height: i64, + variant_card_top: i64, + variant_card_height: i64, + variant_control_offset: i64, + content_left: i64, + header_title_x: i64, + header_title_baseline: i64, + header_subtitle_baseline: i64, + experimental_bounds: Rect, + title_semantic_bounds: Rect, + state_heading_baseline: i64, + state_subtitle_baseline: i64, + state_heading_bounds: Rect, + variant_heading_baseline: i64, + variant_subtitle_baseline: i64, + variant_heading_bounds: Rect, +} + +struct ButtonGallery { + display: DisplayList, + hits: HitTree, + semantics: SemanticsTree, + artifacts: ArrayList[ButtonArtifact], + error: ButtonGalleryError, + button_error: ButtonBuildError, + display_error: DisplayError, + semantics_error: SemanticsError, + glyph_error: GlyphRunError, + button_count: i32, + visible_button_label_count: i32, + text_run_count: i32, + glyph_path_count: i32, + elevation_tier_count: i32, + evidence_hash: i64, +} + +fn button_gallery_width() i32 { return 1280; } +fn button_gallery_height() i32 { return 900; } +fn button_gallery_state_count() i32 { return 8; } +fn button_gallery_variant_count() i32 { return 4; } +fn button_gallery_button_count() i32 { return 12; } + +fn button_gallery_layout_metrics() ButtonGalleryLayoutMetrics { + let density: FlexDensity = FlexDensity.standard; + let canvas_inset: i64 = flex_spacing(FlexSpacingToken.xxlarge, density) + + flex_spacing(FlexSpacingToken.small, density); + let panel_padding: i64 = flex_spacing(FlexSpacingToken.xlarge, density); + let card_gap: i64 = flex_spacing(FlexSpacingToken.xlarge, density); + let card_padding: i64 = flex_spacing(FlexSpacingToken.large, density); + let state_row_gap: i64 = flex_spacing(FlexSpacingToken.large, density) + + flex_spacing(FlexSpacingToken.tiny, density); + let heading_gap: i64 = flex_spacing(FlexSpacingToken.small, density); + let control_height: i64 = button_minimum_target(density) + + flex_spacing(FlexSpacingToken.small, density); + let variant_control_height: i64 = button_minimum_target(density) + + flex_spacing(FlexSpacingToken.medium, density); + let card_width: i64 = 270 * unit_scale(); + let header_top: i64 = flex_spacing(FlexSpacingToken.xlarge, density) + + flex_spacing(FlexSpacingToken.micro, density); + let header_height: i64 = flex_spacing(FlexSpacingToken.xxlarge, density) * 3; + let state_panel_top: i64 = header_top + header_height + + flex_spacing(FlexSpacingToken.xlarge, density); + let heading_band: i64 = flex_spacing(FlexSpacingToken.xxlarge, density) * 2 + + flex_spacing(FlexSpacingToken.large, density); + let state_card_height: i64 = control_height + card_padding * 2 + + flex_spacing(FlexSpacingToken.xxlarge, density) * 2 + state_row_gap; + let state_panel_height: i64 = heading_band + state_card_height * 2 + + state_row_gap + flex_spacing(FlexSpacingToken.xxlarge, density); + let state_card_top: i64 = state_panel_top + heading_band; + let state_control_offset: i64 = + flex_spacing(FlexSpacingToken.xxlarge, density) * 2 + + flex_spacing(FlexSpacingToken.large, density) + + flex_spacing(FlexSpacingToken.medium, density); + let variant_panel_top: i64 = state_panel_top + state_panel_height + + state_row_gap; + let variant_card_top: i64 = variant_panel_top + heading_band; + let variant_card_height: i64 = variant_control_height + card_padding * 2 + + state_row_gap * 2; + let variant_panel_height: i64 = heading_band + variant_card_height + header_top; + let variant_control_offset: i64 = card_padding + + semantic_type_size(SemanticTypeToken.caption) + state_row_gap; + let content_width: i64 = (button_gallery_width() as i64) * unit_scale() - + canvas_inset * 2; + let content_left: i64 = canvas_inset + panel_padding; + let header_title_x: i64 = content_left + + flex_spacing(FlexSpacingToken.micro, density); + let header_title_baseline: i64 = header_top + + flex_spacing(FlexSpacingToken.xxlarge, density) + + flex_spacing(FlexSpacingToken.medium, density); + let header_subtitle_baseline: i64 = header_title_baseline + + semantic_type_size(SemanticTypeToken.body) + + flex_spacing(FlexSpacingToken.large, density); + let experimental_width: i64 = 282 * unit_scale(); + let experimental_height: i64 = button_minimum_target(density); + let experimental_right: i64 = canvas_inset + + flex_spacing(FlexSpacingToken.xxlarge, density) + + flex_spacing(FlexSpacingToken.tiny, density); + let experimental_bounds: Rect = rect( + (button_gallery_width() as i64) * unit_scale() - + experimental_right - experimental_width, + header_top + (header_height - experimental_height) / 2, + experimental_width, experimental_height); + let title_semantic_bounds: Rect = rect(header_title_x, + header_top + flex_spacing(FlexSpacingToken.large, density), + experimental_bounds.x - heading_gap - header_title_x, + header_height - flex_spacing(FlexSpacingToken.large, density) * 2); + let state_heading_baseline: i64 = state_panel_top + + flex_spacing(FlexSpacingToken.xxlarge, density); + let state_subtitle_baseline: i64 = state_heading_baseline + + semantic_type_size(SemanticTypeToken.heading) + heading_gap; + let state_heading_bounds: Rect = rect(content_left, + state_panel_top + flex_spacing(FlexSpacingToken.medium, density), + content_width - panel_padding * 2, + heading_band - flex_spacing(FlexSpacingToken.medium, density) * 2); + let variant_heading_baseline: i64 = variant_panel_top + + flex_spacing(FlexSpacingToken.xxlarge, density); + let variant_subtitle_baseline: i64 = variant_heading_baseline + + semantic_type_size(SemanticTypeToken.heading) + heading_gap; + let variant_heading_bounds: Rect = rect(content_left, + variant_panel_top + flex_spacing(FlexSpacingToken.medium, density), + content_width - panel_padding * 2, + heading_band - flex_spacing(FlexSpacingToken.medium, density) * 2); + return ButtonGalleryLayoutMetrics{ + .canvas_inset = canvas_inset, + .panel_padding = panel_padding, + .card_gap = card_gap, + .card_padding = card_padding, + .state_row_gap = state_row_gap, + .heading_gap = heading_gap, + .control_height = control_height, + .variant_control_height = variant_control_height, + .card_width = card_width, + .column_stride = card_width + card_gap, + .header_top = header_top, + .header_height = header_height, + .state_panel_top = state_panel_top, + .state_panel_height = state_panel_height, + .state_card_top = state_card_top, + .state_card_height = state_card_height, + .state_control_offset = state_control_offset, + .variant_panel_top = variant_panel_top, + .variant_panel_height = variant_panel_height, + .variant_card_top = variant_card_top, + .variant_card_height = variant_card_height, + .variant_control_offset = variant_control_offset, + .content_left = content_left, + .header_title_x = header_title_x, + .header_title_baseline = header_title_baseline, + .header_subtitle_baseline = header_subtitle_baseline, + .experimental_bounds = experimental_bounds, + .title_semantic_bounds = title_semantic_bounds, + .state_heading_baseline = state_heading_baseline, + .state_subtitle_baseline = state_subtitle_baseline, + .state_heading_bounds = state_heading_bounds, + .variant_heading_baseline = variant_heading_baseline, + .variant_subtitle_baseline = variant_subtitle_baseline, + .variant_heading_bounds = variant_heading_bounds, + }; +} + +fn button_gallery_state_id(index: i32) NodeKey { + return node_key(4100 + index as i64); +} + +fn button_gallery_variant_id(index: i32) NodeKey { + return node_key(4200 + index as i64); +} + +fn button_gallery_title_id() NodeKey { return node_key(4300); } +fn button_gallery_state_heading_id() NodeKey { return node_key(4301); } +fn button_gallery_variant_heading_id() NodeKey { return node_key(4302); } +fn button_gallery_experimental_id() NodeKey { return node_key(4303); } + +fn button_gallery_state(index: i32) ButtonCanonicalState { + return switch (index) { + 1 => ButtonCanonicalState.hover, + 2 => ButtonCanonicalState.focus, + 3 => ButtonCanonicalState.pressed, + 4 => ButtonCanonicalState.selected, + 5 => ButtonCanonicalState.disabled, + 6 => ButtonCanonicalState.loading, + 7 => ButtonCanonicalState.error, + else => ButtonCanonicalState.rest, + }; +} + +fn button_gallery_state_name(index: i32) []u8 { + return switch (index) { + 1 => "HOVER", + 2 => "FOCUS", + 3 => "PRESSED", + 4 => "SELECTED", + 5 => "DISABLED", + 6 => "LOADING", + 7 => "ERROR", + else => "REST", + }; +} + +fn button_gallery_state_detail(index: i32) []u8 { + return switch (index) { + 1 => "POINTER / INSIDE", + 2 => "KEYBOARD / FOCUS RING", + 3 => "POINTER / DOWN", + 4 => "STATE / SELECTED", + 5 => "ACTIONS / UNAVAILABLE", + 6 => "PROGRESS / BUSY", + 7 => "STATUS / ERROR", + else => "ENABLED / AT REST", + }; +} + +fn button_gallery_state_description(index: i32) []u8 { + return switch (index) { + 1 => "Button state fixture: pointer hover", + 2 => "Button state fixture: keyboard focus", + 3 => "Button state fixture: pointer pressed", + 4 => "Button state fixture: selected", + 5 => "Button state fixture: disabled and unavailable", + 6 => "Button state fixture: loading and unavailable", + 7 => "Button state fixture: recoverable error", + else => "Button state fixture: enabled and at rest", + }; +} + +fn button_gallery_variant(index: i32) ButtonVariant { + return switch (index) { + 1 => ButtonVariant.secondary, + 2 => ButtonVariant.quiet, + 3 => ButtonVariant.destructive, + else => ButtonVariant.primary, + }; +} + +fn button_gallery_variant_name(index: i32) []u8 { + return switch (index) { + 1 => "SECONDARY", + 2 => "QUIET", + 3 => "DESTRUCTIVE", + else => "PRIMARY", + }; +} + +fn button_gallery_variant_label(index: i32) []u8 { + return switch (index) { + 1 => "Open details", + 2 => "Not now", + 3 => "Delete item", + else => "Continue", + }; +} + +fn button_gallery_variant_description(index: i32) []u8 { + return switch (index) { + 1 => "Secondary action Button variant", + 2 => "Quiet action Button variant", + 3 => "Destructive action Button variant", + else => "Primary action Button variant", + }; +} + +fn button_gallery_empty() ButtonGallery { + return ButtonGallery{ + .display = display_list_make(), + .hits = hit_tree_make(), + .semantics = semantics_tree_make(), + .artifacts = make[ButtonArtifact](button_gallery_button_count()), + .error = ButtonGalleryError.none, + .button_error = ButtonBuildError.none, + .display_error = DisplayError.none, + .semantics_error = SemanticsError.none, + .glyph_error = GlyphRunError.none, + .button_count = 0, + .visible_button_label_count = 0, + .text_run_count = 0, + .glyph_path_count = 0, + .elevation_tier_count = 3, + .evidence_hash = 0, + }; +} + +fn button_gallery_free(gallery: *ButtonGallery) void { + free[ButtonArtifact](&gallery.*.artifacts); + semantics_tree_free(&gallery.*.semantics); + hit_tree_free(&gallery.*.hits); + display_list_free(&gallery.*.display); +} + +fn button_gallery_text_result(error: ButtonGalleryError, + glyph_error: GlyphRunError) ButtonGalleryTextResult { + return ButtonGalleryTextResult{ + .error = error, .glyph_error = glyph_error, + .glyph_count = 0, .path_count = 0, .advance = 0, + }; +} + +fn button_gallery_push_round(list: *DisplayList, owner: NodeKey, bounds: Rect, + radius: i64, paint: Paint) i32 { + let resolved_radius: i64 = radius; + if (resolved_radius * 2 > bounds.width) { resolved_radius = bounds.width / 2; } + if (resolved_radius * 2 > bounds.height) { resolved_radius = bounds.height / 2; } + if (resolved_radius <= 0) { return 0; } + let op: DisplayOp = display_op(DisplayOpKind.fill_rounded_rect, owner); + op.bounds = bounds; op.parameter = resolved_radius; op.paint = paint; + return (display_list_push(list, op) == DisplayError.none) as i32; +} + +fn button_gallery_push_rect(list: *DisplayList, owner: NodeKey, bounds: Rect, + paint: Paint) i32 { + let op: DisplayOp = display_op(DisplayOpKind.fill_rect, owner); + op.bounds = bounds; op.paint = paint; + return (display_list_push(list, op) == DisplayError.none) as i32; +} + +fn button_gallery_surface(list: *DisplayList, owner: NodeKey, bounds: Rect, + radius_token: SemanticRadiusToken, fill_token: SemanticColorToken, + elevation_token: SemanticElevationToken) i32 { + let elevation: ResolvedElevationToken = semantic_elevation(elevation_token); + let radius: i64 = semantic_radius(radius_token); + if (elevation.shadow_alpha > 0) { + let spread: i64 = elevation.shadow_spread; + if (button_gallery_push_round(list, owner, + rect(bounds.x - spread, + bounds.y + elevation.shadow_offset_y - spread, + bounds.width + spread * 2, bounds.height + spread * 2), + radius + spread, semantic_color_with_alpha( + SemanticColorToken.shadow, elevation.shadow_alpha)) == 0) { + return 0; + } + } + if (button_gallery_push_round(list, owner, bounds, radius, + semantic_color_with_alpha(SemanticColorToken.border_subtle, + elevation.border_alpha)) == 0) { return 0; } + let edge: i64 = unit_scale(); + return button_gallery_push_round(list, owner, + rect(bounds.x + edge, bounds.y + edge, + bounds.width - edge * 2, bounds.height - edge * 2), + radius - edge, semantic_color_paint(fill_token)); +} + +fn button_gallery_emit_run(list: *DisplayList, face: OpenTypeFace, + resource_id: i64, owner: NodeKey, text: []u8, font_size: i64, + origin_x: i64, baseline_y: i64, paint: Paint) ButtonGalleryTextResult { + let run: NominalGlyphRun = nominal_glyph_run_ltr( + face, text, font_size, origin_x, baseline_y); + if (run.error != GlyphRunError.none) { + let failure: ButtonGalleryTextResult = button_gallery_text_result( + ButtonGalleryError.text_shape_error, run.error); + nominal_glyph_run_free(&run); return failure; + } + let result: ButtonGalleryTextResult = button_gallery_text_result( + ButtonGalleryError.none, GlyphRunError.none); + result.glyph_count = run.glyphs.len; result.advance = run.total_advance; + let glyph_index: i32 = 0; + while (glyph_index < run.glyphs.len) { + let glyph: PositionedGlyph = run.glyphs.data[glyph_index]; + let outline: GlyphOutline = opentype_glyph_outline(face, glyph.glyph_id); + if (outline.error != OpenTypeError.none) { + opentype_glyph_outline_free(&outline); + result.error = ButtonGalleryError.text_path_error; + result.glyph_error = GlyphRunError.invalid_outline; + nominal_glyph_run_free(&run); return result; + } + if (outline.points.len > 0) { + let path: GlyphPathResult = glyph_outline_to_path(outline, + face.units_per_em, run.font_size, + glyph.origin_x, glyph.origin_y); + opentype_glyph_outline_free(&outline); + if (path.error != GlyphPathError.none) { + glyph_path_result_free(&path); + result.error = ButtonGalleryError.text_path_error; + result.glyph_error = GlyphRunError.path_error; + nominal_glyph_run_free(&run); return result; + } + let encoded: PathEncodeResult = path_encode(path.path); + glyph_path_result_free(&path); + if (encoded.error != PathError.none) { + path_encode_free(&encoded); + result.error = ButtonGalleryError.text_path_error; + nominal_glyph_run_free(&run); return result; + } + let glyph_resource_id: i64 = resource_id + glyph_index as i64; + let resource_error: RenderResourceError = display_list_add_resource(list, + render_resource_spec(glyph_resource_id, RenderResourceKind.path, + encoded.bytes.data[0..encoded.bytes.len], + path_resource_format_tag())); + path_encode_free(&encoded); + if (resource_error != RenderResourceError.none) { + result.error = ButtonGalleryError.text_resource_error; + nominal_glyph_run_free(&run); return result; + } + let draw: DisplayOp = display_op(DisplayOpKind.draw_path, owner); + draw.resource_id = glyph_resource_id; draw.paint = paint; + if (display_list_push(list, draw) != DisplayError.none) { + result.error = ButtonGalleryError.display_error; + nominal_glyph_run_free(&run); return result; + } + result.path_count = result.path_count + 1; + } else { + opentype_glyph_outline_free(&outline); + } + glyph_index = glyph_index + 1; + } + nominal_glyph_run_free(&run); + return result; +} + +fn button_gallery_emit_centered_run(list: *DisplayList, face: OpenTypeFace, + resource_id: i64, owner: NodeKey, text: []u8, font_size: i64, + bounds: Rect, paint: Paint) ButtonGalleryTextResult { + let measured: NominalGlyphRun = nominal_glyph_run_ltr( + face, text, font_size, 0, 0); + if (measured.error != GlyphRunError.none) { + let error: GlyphRunError = measured.error; + nominal_glyph_run_free(&measured); + return button_gallery_text_result( + ButtonGalleryError.text_shape_error, error); + } + let x: i64 = bounds.x + (bounds.width - measured.total_advance) / 2; + let baseline: i64 = bounds.y + bounds.height / 2 + font_size / 3; + nominal_glyph_run_free(&measured); + return button_gallery_emit_run(list, face, resource_id, owner, text, + font_size, x, baseline, paint); +} + +fn button_gallery_record_text(gallery: *ButtonGallery, + result: ButtonGalleryTextResult) i32 { + if (result.error == ButtonGalleryError.none) { + gallery.*.text_run_count = gallery.*.text_run_count + 1; + gallery.*.glyph_path_count = gallery.*.glyph_path_count + + result.path_count; + return 1; + } + gallery.*.error = result.error; + gallery.*.glyph_error = result.glyph_error; + if (result.error == ButtonGalleryError.display_error) { + gallery.*.display_error = gallery.*.display.last_error; + } + return 0; +} + +fn button_gallery_semantic_text(gallery: *ButtonGallery, id: NodeKey, + name: []u8, description: []u8, bounds: Rect) i32 { + let spec: SemanticsSpec = semantics_spec( + id, semantic_root_key(), SemanticRole.text, name); + spec.description = description; spec.has_bounds = 1; spec.bounds = bounds; + let error: SemanticsError = semantics_add(&gallery.*.semantics, spec); + if (error == SemanticsError.none) { return 1; } + gallery.*.error = ButtonGalleryError.semantics_error; + gallery.*.semantics_error = error; + return 0; +} + +fn button_gallery_background(gallery: *ButtonGallery) i32 { + let scale: i64 = unit_scale(); + let metrics: ButtonGalleryLayoutMetrics = button_gallery_layout_metrics(); + let content_width: i64 = (button_gallery_width() as i64) * scale - + metrics.canvas_inset * 2; + if (button_gallery_push_rect(&gallery.*.display, node_key(9000), + rect(0, 0, (button_gallery_width() as i64) * scale, + (button_gallery_height() as i64) * scale), + semantic_color_paint(SemanticColorToken.canvas)) == 0) { return 0; } + if (button_gallery_surface(&gallery.*.display, node_key(9001), + rect(metrics.canvas_inset, metrics.header_top, + content_width, metrics.header_height), + SemanticRadiusToken.card, SemanticColorToken.surface_raised, + SemanticElevationToken.raised) == 0) { return 0; } + if (button_gallery_push_rect(&gallery.*.display, node_key(9002), + rect(metrics.canvas_inset, metrics.header_top, + flex_spacing(FlexSpacingToken.small, FlexDensity.standard) - scale, + metrics.header_height), + semantic_color_paint(SemanticColorToken.accent)) == 0) { return 0; } + if (button_gallery_surface(&gallery.*.display, node_key(9010), + rect(metrics.canvas_inset, metrics.state_panel_top, + content_width, metrics.state_panel_height), + SemanticRadiusToken.panel, SemanticColorToken.surface_panel, + SemanticElevationToken.panel) == 0) { return 0; } + if (button_gallery_surface(&gallery.*.display, node_key(9020), + rect(metrics.canvas_inset, metrics.variant_panel_top, + content_width, metrics.variant_panel_height), + SemanticRadiusToken.panel, SemanticColorToken.surface_panel, + SemanticElevationToken.panel) == 0) { return 0; } + let state_index: i32 = 0; + while (state_index < button_gallery_state_count()) { + let column: i32 = state_index % 4; + let row: i32 = state_index / 4; + let x: i64 = metrics.canvas_inset + metrics.panel_padding + + (column as i64) * metrics.column_stride; + let y: i64 = metrics.state_card_top + + (row as i64) * (metrics.state_card_height + metrics.state_row_gap); + if (button_gallery_surface(&gallery.*.display, + node_key(9100 + state_index as i64), + rect(x, y, metrics.card_width, metrics.state_card_height), + SemanticRadiusToken.card, SemanticColorToken.surface_inset, + SemanticElevationToken.base) == 0) { return 0; } + state_index = state_index + 1; + } + let variant_index: i32 = 0; + while (variant_index < button_gallery_variant_count()) { + let x: i64 = metrics.canvas_inset + metrics.panel_padding + + (variant_index as i64) * metrics.column_stride; + if (button_gallery_surface(&gallery.*.display, + node_key(9200 + variant_index as i64), + rect(x, metrics.variant_card_top, + metrics.card_width, metrics.variant_card_height), + SemanticRadiusToken.card, SemanticColorToken.surface_inset, + SemanticElevationToken.base) == 0) { return 0; } + variant_index = variant_index + 1; + } + return 1; +} + +fn button_gallery_static_text(gallery: *ButtonGallery, + face: OpenTypeFace) i32 { + let metrics: ButtonGalleryLayoutMetrics = button_gallery_layout_metrics(); + if (button_gallery_record_text(gallery, button_gallery_emit_run( + &gallery.*.display, face, 50000, button_gallery_title_id(), + "Button / interaction conformance", semantic_type_size( + SemanticTypeToken.title), metrics.header_title_x, + metrics.header_title_baseline, + semantic_color_paint(SemanticColorToken.text_primary))) == 0) { return 0; } + if (button_gallery_record_text(gallery, button_gallery_emit_run( + &gallery.*.display, face, 50100, button_gallery_title_id(), + "One retained component. One identity across pixels, focus, semantics, and Talkback.", + semantic_type_size(SemanticTypeToken.body), metrics.header_title_x, + metrics.header_subtitle_baseline, + semantic_color_paint(SemanticColorToken.text_secondary))) == 0) { return 0; } + if (button_gallery_push_round(&gallery.*.display, + button_gallery_experimental_id(), + metrics.experimental_bounds, + semantic_radius(SemanticRadiusToken.pill), + semantic_color_with_alpha(SemanticColorToken.status_experimental, + 18000)) == 0) { return 0; } + if (button_gallery_record_text(gallery, button_gallery_emit_centered_run( + &gallery.*.display, face, 50200, button_gallery_experimental_id(), + "EXPERIMENTAL / RFC 0007 OPEN", + semantic_type_size(SemanticTypeToken.caption), + metrics.experimental_bounds, + semantic_color_paint(SemanticColorToken.status_experimental))) == 0) { return 0; } + if (button_gallery_record_text(gallery, button_gallery_emit_run( + &gallery.*.display, face, 50300, button_gallery_state_heading_id(), + "Canonical interaction states", + semantic_type_size(SemanticTypeToken.heading), + metrics.content_left, metrics.state_heading_baseline, + semantic_color_paint(SemanticColorToken.text_primary))) == 0) { return 0; } + if (button_gallery_record_text(gallery, button_gallery_emit_run( + &gallery.*.display, face, 50400, button_gallery_state_heading_id(), + "The same secondary Button, with state expressed in shape, elevation, semantics, and action truth.", + semantic_type_size(SemanticTypeToken.caption), + metrics.content_left, metrics.state_subtitle_baseline, + semantic_color_paint(SemanticColorToken.text_secondary))) == 0) { return 0; } + if (button_gallery_record_text(gallery, button_gallery_emit_run( + &gallery.*.display, face, 50500, button_gallery_variant_heading_id(), + "Semantic variants", + semantic_type_size(SemanticTypeToken.heading), + metrics.content_left, metrics.variant_heading_baseline, + semantic_color_paint(SemanticColorToken.text_primary))) == 0) { return 0; } + if (button_gallery_record_text(gallery, button_gallery_emit_run( + &gallery.*.display, face, 50600, button_gallery_variant_heading_id(), + "Rest state isolates role differences. Literal colors are not component API.", + semantic_type_size(SemanticTypeToken.caption), + metrics.content_left, metrics.variant_subtitle_baseline, + semantic_color_paint(SemanticColorToken.text_secondary))) == 0) { return 0; } + return 1; +} + +fn button_gallery_emit_button(gallery: *ButtonGallery, face: OpenTypeFace, + spec: ButtonSpec, text_resource_id: i64) i32 { + let built: ButtonBuildResult = button_emit( + spec, &gallery.*.display, &gallery.*.hits, &gallery.*.semantics); + if (built.error != ButtonBuildError.none) { + gallery.*.error = ButtonGalleryError.button_error; + gallery.*.button_error = built.error; + gallery.*.display_error = built.display_error; + gallery.*.semantics_error = built.semantics_error; + return 0; + } + push[ButtonArtifact](&gallery.*.artifacts, built.artifact); + gallery.*.button_count = gallery.*.button_count + 1; + if (built.artifact.content_visible != 0) { + let text: ButtonGalleryTextResult = button_gallery_emit_centered_run( + &gallery.*.display, face, text_resource_id, spec.id, spec.label, + semantic_type_size(SemanticTypeToken.label), + built.artifact.content_bounds, + semantic_color_paint(built.artifact.style.text_token)); + if (button_gallery_record_text(gallery, text) == 0) { return 0; } + gallery.*.visible_button_label_count = + gallery.*.visible_button_label_count + 1; + } + return 1; +} + +fn button_gallery_state_matrix(gallery: *ButtonGallery, + face: OpenTypeFace) i32 { + let scale: i64 = unit_scale(); + let metrics: ButtonGalleryLayoutMetrics = button_gallery_layout_metrics(); + let index: i32 = 0; + while (index < button_gallery_state_count()) { + let column: i32 = index % 4; + let row: i32 = index / 4; + let x: i64 = metrics.canvas_inset + metrics.panel_padding + + (column as i64) * metrics.column_stride; + let y: i64 = metrics.state_card_top + + (row as i64) * (metrics.state_card_height + metrics.state_row_gap); + if (button_gallery_record_text(gallery, button_gallery_emit_run( + &gallery.*.display, face, 51000 + (index as i64) * 100, + node_key(9300 + index as i64), button_gallery_state_name(index), + semantic_type_size(SemanticTypeToken.label), + x + metrics.card_padding, + y + metrics.card_padding + + semantic_type_size(SemanticTypeToken.label), + semantic_color_paint(SemanticColorToken.text_primary))) == 0) { return 0; } + if (button_gallery_record_text(gallery, button_gallery_emit_run( + &gallery.*.display, face, 51050 + (index as i64) * 100, + node_key(9400 + index as i64), button_gallery_state_detail(index), + semantic_type_size(SemanticTypeToken.caption), + x + metrics.card_padding, + y + metrics.card_padding + + semantic_type_size(SemanticTypeToken.label) + + metrics.heading_gap + + semantic_type_size(SemanticTypeToken.caption), + semantic_color_paint(SemanticColorToken.text_secondary))) == 0) { return 0; } + let spec: ButtonSpec = button_spec(button_gallery_state_id(index), + hit_root_key(), rect(x + metrics.card_padding, + y + metrics.state_control_offset, + metrics.card_width - metrics.card_padding * 2, + metrics.control_height), "Run build"); + spec.description = button_gallery_state_description(index); + spec.focus_order = 1 + index as i64; + spec.z_order = 100 + index as i64; + spec.variant = ButtonVariant.secondary; + spec.interaction = button_canonical_state(button_gallery_state(index)); + if (button_gallery_emit_button(gallery, face, spec, + 53000 + (index as i64) * 100) == 0) { return 0; } + index = index + 1; + } + return 1; +} + +fn button_gallery_variant_matrix(gallery: *ButtonGallery, + face: OpenTypeFace) i32 { + let scale: i64 = unit_scale(); + let metrics: ButtonGalleryLayoutMetrics = button_gallery_layout_metrics(); + let index: i32 = 0; + while (index < button_gallery_variant_count()) { + let x: i64 = metrics.canvas_inset + metrics.panel_padding + + (index as i64) * metrics.column_stride; + if (button_gallery_record_text(gallery, button_gallery_emit_run( + &gallery.*.display, face, 55000 + (index as i64) * 100, + node_key(9500 + index as i64), button_gallery_variant_name(index), + semantic_type_size(SemanticTypeToken.caption), + x + metrics.card_padding, + metrics.variant_card_top + metrics.card_padding + + semantic_type_size(SemanticTypeToken.caption), + semantic_color_paint(SemanticColorToken.text_secondary))) == 0) { return 0; } + let spec: ButtonSpec = button_spec(button_gallery_variant_id(index), + hit_root_key(), rect(x + metrics.card_padding, + metrics.variant_card_top + metrics.variant_control_offset, + metrics.card_width - metrics.card_padding * 2, + metrics.variant_control_height), + button_gallery_variant_label(index)); + spec.description = button_gallery_variant_description(index); + spec.focus_order = 9 + index as i64; + spec.z_order = 200 + index as i64; + spec.variant = button_gallery_variant(index); + spec.interaction = button_canonical_state(ButtonCanonicalState.rest); + if (button_gallery_emit_button(gallery, face, spec, + 56000 + (index as i64) * 100) == 0) { return 0; } + index = index + 1; + } + return 1; +} + +fn button_gallery_hash(gallery: ButtonGallery) i64 { + let hash: i64 = button_hash_mix(17, gallery.display.content_hash); + hash = button_hash_mix(hash, gallery.display.ops.len as i64); + hash = button_hash_mix(hash, gallery.display.resources.resources.len as i64); + hash = button_hash_mix(hash, gallery.hits.revision); + hash = button_hash_mix(hash, gallery.semantics.revision); + hash = button_hash_mix(hash, gallery.button_count as i64); + hash = button_hash_mix(hash, gallery.visible_button_label_count as i64); + hash = button_hash_mix(hash, gallery.text_run_count as i64); + hash = button_hash_mix(hash, gallery.glyph_path_count as i64); + let i: i32 = 0; + while (i < gallery.artifacts.len) { + hash = button_hash_mix(hash, gallery.artifacts.data[i].evidence_hash); + i = i + 1; + } + return hash; +} + +fn button_gallery_build(face: OpenTypeFace) ButtonGallery { + let gallery: ButtonGallery = button_gallery_empty(); + let metrics: ButtonGalleryLayoutMetrics = button_gallery_layout_metrics(); + if (face.error != OpenTypeError.none || face.units_per_em <= 0) { + gallery.error = ButtonGalleryError.invalid_font; return gallery; + } + if (button_gallery_background(&gallery) == 0) { + gallery.error = ButtonGalleryError.display_error; + gallery.display_error = gallery.display.last_error; return gallery; + } + if (button_gallery_semantic_text(&gallery, button_gallery_title_id(), + "Button interaction conformance", + "Experimental component contract showcase", + metrics.title_semantic_bounds) == 0) { return gallery; } + if (button_gallery_semantic_text(&gallery, + button_gallery_experimental_id(), "Experimental", + "Visual direction RFC 0007 remains open", + metrics.experimental_bounds) == 0) { return gallery; } + if (button_gallery_semantic_text(&gallery, + button_gallery_state_heading_id(), "Canonical interaction states", + "The same secondary Button across eight states", + metrics.state_heading_bounds) == 0) { return gallery; } + if (button_gallery_semantic_text(&gallery, + button_gallery_variant_heading_id(), "Semantic variants", + "Four Button roles shown at rest", + metrics.variant_heading_bounds) == 0) { return gallery; } + if (button_gallery_static_text(&gallery, face) == 0) { return gallery; } + if (button_gallery_state_matrix(&gallery, face) == 0) { return gallery; } + if (button_gallery_variant_matrix(&gallery, face) == 0) { return gallery; } + let sealed: DisplayError = display_list_seal(&gallery.display); + if (sealed != DisplayError.none) { + gallery.error = ButtonGalleryError.display_error; + gallery.display_error = sealed; return gallery; + } + gallery.evidence_hash = button_gallery_hash(gallery); + return gallery; +} diff --git a/tests/button_gallery_contract.zag b/tests/button_gallery_contract.zag new file mode 100644 index 0000000..b73dc1d --- /dev/null +++ b/tests/button_gallery_contract.zag @@ -0,0 +1,265 @@ +@import("../src/showcase/button_gallery.zag") +@import("../src/automation/talkback.zag") +@import("../src/render/cpu_raster.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn same_rect(left: Rect, right: Rect) i32 { + return (left.x == right.x && left.y == right.y && + left.width == right.width && left.height == right.height) as i32; +} + +fn owner_has_path(list: DisplayList, owner: NodeKey) i32 { + let index: i32 = 0; + while (index < list.ops.len) { + if (list.ops.data[index].kind == DisplayOpKind.draw_path && + node_key_equal(list.ops.data[index].owner, owner) != 0) { return 1; } + index = index + 1; + } + return 0; +} + +fn gallery_face(path: []u8) OpenTypeFace { + let bytes: []u8 = _zag_read_file(path); + if (bytes.len <= 0) { return opentype_face_empty(); } + let face: OpenTypeFace = opentype_parse(bytes); + _zag_free(_zag_slice_ptr(bytes)); + return face; +} + +fn tree_contract(state: *TestState, gallery: *ButtonGallery) void { + expect(state, gallery.*.error == ButtonGalleryError.none && + gallery.*.button_count == 12 && gallery.*.artifacts.len == 12, + "gallery builds all twelve examples through Button emission"); + expect(state, gallery.*.hits.nodes.len == 12 && + gallery.*.semantics.nodes.len == 16 && + gallery.*.semantics.revision == 16, + "one retained hit tree and semantics tree cover controls and headings"); + expect(state, gallery.*.visible_button_label_count == 11 && + gallery.*.text_run_count == 38 && gallery.*.glyph_path_count > 38 && + gallery.*.display.resources.resources.len == gallery.*.glyph_path_count, + "every visible label and heading is an owned OpenType path resource"); + let draw_paths: i32 = 0; + let index: i32 = 0; + while (index < gallery.*.display.ops.len) { + if (gallery.*.display.ops.data[index].kind == DisplayOpKind.draw_path) { + draw_paths = draw_paths + 1; + } + index = index + 1; + } + expect(state, draw_paths == gallery.*.glyph_path_count, + "gallery text uses real draw_path operations without placeholder bars"); + let metrics: ButtonGalleryLayoutMetrics = button_gallery_layout_metrics(); + expect(state, metrics.canvas_inset == + flex_spacing(FlexSpacingToken.xxlarge, FlexDensity.standard) + + flex_spacing(FlexSpacingToken.small, FlexDensity.standard) && + metrics.card_gap == flex_spacing(FlexSpacingToken.xlarge, + FlexDensity.standard) && + metrics.card_padding == flex_spacing(FlexSpacingToken.large, + FlexDensity.standard) && + metrics.column_stride == metrics.card_width + metrics.card_gap, + "repeated gallery rhythm resolves through named Flex spacing tokens"); + expect(state, gallery.*.elevation_tier_count == 3 && + gallery.*.artifacts.data[8].style.elevation == SemanticElevationToken.raised && + gallery.*.artifacts.data[9].style.elevation == SemanticElevationToken.panel && + gallery.*.artifacts.data[10].style.elevation == SemanticElevationToken.base, + "base panel and raised elevation tiers remain inspectable token truth"); + + let state_index: i32 = 0; + let state_truth: i32 = 1; + let geometry_truth: i32 = 1; + while (state_index < button_gallery_state_count()) { + let artifact: ButtonArtifact = gallery.*.artifacts.data[state_index]; + if (node_key_equal(artifact.id, + button_gallery_state_id(state_index)) == 0 || + button_interaction_equal(artifact.interaction, + button_canonical_state(button_gallery_state(state_index))) == 0) { + state_truth = 0; + } + let hit_index: i32 = hit_tree_find_index( + gallery.*.hits, button_gallery_state_id(state_index)); + let semantic_index: i32 = semantics_find_index( + gallery.*.semantics, button_gallery_state_id(state_index)); + if (hit_index < 0 || semantic_index < 0 || + same_rect(gallery.*.hits.nodes.data[hit_index].bounds, + artifact.bounds) == 0 || + same_rect(gallery.*.semantics.nodes.data[semantic_index].bounds, + artifact.bounds) == 0) { + geometry_truth = 0; + } + state_index = state_index + 1; + } + expect(state, state_truth != 0, + "the same secondary Button exposes all eight canonical interactions"); + expect(state, geometry_truth != 0, + "display artifacts hit targets semantics and Talkback share exact geometry"); + expect(state, gallery.*.artifacts.data[5].effectively_enabled == 0 && + gallery.*.artifacts.data[6].effectively_enabled == 0 && + gallery.*.artifacts.data[6].content_visible == 0 && + gallery.*.artifacts.data[4].interaction.selected == 1, + "disabled loading selected and content substitution remain explicit state"); + let focus: ButtonArtifact = gallery.*.artifacts.data[2]; + let loading: ButtonArtifact = gallery.*.artifacts.data[6]; + let error_semantic_index: i32 = semantics_find_index( + gallery.*.semantics, button_gallery_state_id(7)); + expect(state, + gallery.*.display.ops.data[focus.display_op_start].bounds.width > + focus.bounds.width && + gallery.*.display.ops.data[loading.display_op_end - 1].bounds.width < + loading.bounds.width && + error_semantic_index >= 0 && semantic_text_equal( + gallery.*.semantics.nodes.data[error_semantic_index].value, + "Error") != 0 && owner_has_path(gallery.*.display, + node_key(9307)) != 0, + "focus loading and error carry ring progress and text evidence beyond color"); + expect(state, gallery.*.artifacts.data[8].style.fill_token == + SemanticColorToken.accent && + gallery.*.artifacts.data[9].style.fill_token == + SemanticColorToken.surface_interactive && + gallery.*.artifacts.data[10].style.fill_alpha == 0 && + gallery.*.artifacts.data[11].style.fill_token == + SemanticColorToken.status_error, + "primary secondary quiet and destructive variants resolve semantic roles"); + expect(state, semantics_find_index(gallery.*.semantics, + button_gallery_title_id()) >= 0 && + semantics_find_index(gallery.*.semantics, + button_gallery_experimental_id()) >= 0, + "title and experimental visual-direction truth are semantic text"); + let title_index: i32 = semantics_find_index(gallery.*.semantics, + button_gallery_title_id()); + let experimental_index: i32 = semantics_find_index(gallery.*.semantics, + button_gallery_experimental_id()); + let state_heading_index: i32 = semantics_find_index(gallery.*.semantics, + button_gallery_state_heading_id()); + let variant_heading_index: i32 = semantics_find_index(gallery.*.semantics, + button_gallery_variant_heading_id()); + expect(state, title_index >= 0 && experimental_index >= 0 && + state_heading_index >= 0 && variant_heading_index >= 0 && + same_rect(gallery.*.semantics.nodes.data[title_index].bounds, + metrics.title_semantic_bounds) != 0 && + same_rect(gallery.*.semantics.nodes.data[experimental_index].bounds, + metrics.experimental_bounds) != 0 && + same_rect(gallery.*.semantics.nodes.data[state_heading_index].bounds, + metrics.state_heading_bounds) != 0 && + same_rect(gallery.*.semantics.nodes.data[variant_heading_index].bounds, + metrics.variant_heading_bounds) != 0, + "visible headings and semantic bounds share the named gallery metrics"); + expect(state, gallery.*.display.sealed != 0 && + display_list_verify(gallery.*.display) == DisplayError.none && + gallery.*.evidence_hash != 0, + "gallery seals into one immutable deterministic DisplayList"); +} + +fn talkback_contract(state: *TestState, gallery: *ButtonGallery) void { + let session: TalkbackSession = talkback_session_make(talkback_capabilities()); + let viewport: Rect = rect(0, 0, + (button_gallery_width() as i64) * unit_scale(), + (button_gallery_height() as i64) * unit_scale()); + let rest: TalkbackResponse = talkback_dispatch(&session, + gallery.*.semantics, viewport, talkback_request(1, + TalkbackCommand.click, button_gallery_state_id(0), + gallery.*.semantics.revision)); + let disabled: TalkbackResponse = talkback_dispatch(&session, + gallery.*.semantics, viewport, talkback_request(2, + TalkbackCommand.click, button_gallery_state_id(5), + gallery.*.semantics.revision)); + let loading: TalkbackResponse = talkback_dispatch(&session, + gallery.*.semantics, viewport, talkback_request(3, + TalkbackCommand.click, button_gallery_state_id(6), + gallery.*.semantics.revision)); + let destructive: TalkbackResponse = talkback_dispatch(&session, + gallery.*.semantics, viewport, talkback_request(4, + TalkbackCommand.query, button_gallery_variant_id(3), + gallery.*.semantics.revision)); + expect(state, rest.status == TalkbackStatus.accepted && + rest.target_id.value == 4100 && rest.emitted_action == 1, + "Talkback activates the rest Button by its stable ID"); + expect(state, disabled.status == TalkbackStatus.action_unavailable && + loading.status == TalkbackStatus.action_unavailable, + "Talkback observes shared disabled and loading action truth"); + expect(state, destructive.status == TalkbackStatus.accepted && + destructive.target_id.value == 4203 && + destructive.resolved_role == SemanticRole.button, + "variant IDs remain stable semantic query targets"); + let pixel: TalkbackRequest = talkback_pixel_request(5, + TalkbackCommand.click, 100, 100, 1, 1); + let pixel_response: TalkbackResponse = talkback_dispatch(&session, + gallery.*.semantics, viewport, pixel); + expect(state, pixel_response.status == TalkbackStatus.pixel_fallback_disabled, + "the conformance surface proves ID targeting without hiding behind pixels"); + talkback_session_free(&session); +} + +fn deterministic_cpu_contract(state: *TestState, face: OpenTypeFace, + first: *ButtonGallery) void { + let second: ButtonGallery = button_gallery_build(face); + expect(state, second.error == ButtonGalleryError.none && + second.evidence_hash == first.*.evidence_hash && + second.display.content_hash == first.*.display.content_hash, + "rebuilding with the same owned font produces identical retained evidence"); + let first_raster: CpuRasterResult = cpu_rasterize(first.*.display, + button_gallery_width(), button_gallery_height()); + let second_raster: CpuRasterResult = cpu_rasterize(second.display, + button_gallery_width(), button_gallery_height()); + if (first_raster.error != CpuRasterError.none || + second_raster.error != CpuRasterError.none) { + _zag_print("first CPU error/op: "); + _zag_print_i64(first_raster.error as i64); _zag_print(" / "); + _zag_print_i64(first_raster.error_op as i64); _zag_println(""); + _zag_print("second CPU error/op: "); + _zag_print_i64(second_raster.error as i64); _zag_print(" / "); + _zag_print_i64(second_raster.error_op as i64); _zag_println(""); + } + expect(state, first_raster.error == CpuRasterError.none && + second_raster.error == CpuRasterError.none && + first_raster.pixels_touched > 0 && second_raster.pixels_touched > 0, + "the complete gallery executes through the deterministic CPU oracle"); + let first_hash: i64 = 0; + let second_hash: i64 = 1; + if (first_raster.error == CpuRasterError.none) { + first_hash = cpu_surface_hash(first_raster.surface); + } + if (second_raster.error == CpuRasterError.none) { + second_hash = cpu_surface_hash(second_raster.surface); + } + expect(state, first_hash != 0 && first_hash == second_hash, + "two CPU renders are pixel-identical for the same font and display list"); + _zag_print("button gallery evidence hash: "); + _zag_print_i64(first.*.evidence_hash); _zag_println(""); + _zag_print("button gallery CPU hash: "); + _zag_print_i64(first_hash); _zag_println(""); + cpu_raster_free(&second_raster); cpu_raster_free(&first_raster); + button_gallery_free(&second); +} + +fn main() i32 { + if (_zag_argc() != 2) { + _zag_eprintln("usage: button_gallery_contract "); + return 2; + } + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + let face: OpenTypeFace = gallery_face(_zag_arg(1)); + expect(&state, face.error == OpenTypeError.none, + "test font parses through the Zag-owned OpenType reader"); + let gallery: ButtonGallery = button_gallery_build(face); + tree_contract(&state, &gallery); + talkback_contract(&state, &gallery); + deterministic_cpu_contract(&state, face, &gallery); + button_gallery_free(&gallery); opentype_face_free(&face); + _zag_print("Button gallery contract: pass="); + _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); + _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/render-button-gallery.sh b/tools/render-button-gallery.sh new file mode 100755 index 0000000..bb4985a --- /dev/null +++ b/tools/render-button-gallery.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +font=${1:-} +output=${2:-$root/artifacts/evidence/button-gallery-experimental.png} +znc=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} + +if [ -z "$font" ] && command -v fc-match >/dev/null 2>&1; then + font=$(fc-match -f '%{file}\n' 'Noto Sans' | head -n 1) +fi +if [ -z "$font" ] || [ ! -f "$font" ]; then + printf 'button gallery: FAIL: no font file resolved\n' >&2 + exit 2 +fi + +mkdir -p "$(dirname -- "$output")" +binary=$(mktemp /tmp/zagkit-render-button-gallery.XXXXXX) +cleanup() { rm -f -- "$binary"; } +trap cleanup EXIT + +"$znc" "$root/tools/render-button-gallery.zag" --no-zagd \ + --analyze-strict --no-foreground-cache -o "$binary" +"$binary" "$font" "$output" +printf 'button gallery: PASS (%s using %s)\n' "$output" "$font" diff --git a/tools/render-button-gallery.zag b/tools/render-button-gallery.zag new file mode 100644 index 0000000..0a113a3 --- /dev/null +++ b/tools/render-button-gallery.zag @@ -0,0 +1,42 @@ +@import("../src/showcase/button_gallery.zag") +@import("../src/render/png_encode.zag") + +fn main() i32 { + if (_zag_argc() != 3) { + _zag_eprintln("usage: render-button-gallery "); + return 2; + } + let font_bytes: []u8 = _zag_read_file(_zag_arg(1)); + if (font_bytes.len <= 0) { return 3; } + let face: OpenTypeFace = opentype_parse(font_bytes); + _zag_free(_zag_slice_ptr(font_bytes)); + if (face.error != OpenTypeError.none) { + opentype_face_free(&face); return 4; + } + let gallery: ButtonGallery = button_gallery_build(face); + if (gallery.error != ButtonGalleryError.none || + display_list_verify(gallery.display) != DisplayError.none) { + button_gallery_free(&gallery); opentype_face_free(&face); return 5; + } + let raster: CpuRasterResult = cpu_rasterize(gallery.display, + button_gallery_width(), button_gallery_height()); + if (raster.error != CpuRasterError.none) { + cpu_raster_free(&raster); button_gallery_free(&gallery); + opentype_face_free(&face); return 6; + } + let png: PngEncodeResult = png_encode_surface(raster.surface); + if (png.error != PngEncodeError.none) { + png_encode_free(&png); cpu_raster_free(&raster); + button_gallery_free(&gallery); opentype_face_free(&face); return 7; + } + let write_error: i32 = _zag_write_file( + _zag_arg(2), png.bytes.data[0..png.bytes.len]); + _zag_print("button gallery evidence hash: "); + _zag_print_i64(gallery.evidence_hash); _zag_println(""); + _zag_print("button gallery CPU hash: "); + _zag_print_i64(cpu_surface_hash(raster.surface)); _zag_println(""); + png_encode_free(&png); cpu_raster_free(&raster); + button_gallery_free(&gallery); opentype_face_free(&face); + if (write_error != 0) { return 8; } + return 0; +} diff --git a/tools/test-button-gallery.sh b/tools/test-button-gallery.sh new file mode 100755 index 0000000..b65f3ad --- /dev/null +++ b/tools/test-button-gallery.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +font=${1:-} +znc=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} + +if [ -z "$font" ] && command -v fc-match >/dev/null 2>&1; then + font=$(fc-match -f '%{file}\n' 'Noto Sans' | head -n 1) +fi +if [ -z "$font" ] || [ ! -f "$font" ]; then + printf 'button gallery contract: FAIL: no font file resolved\n' >&2 + exit 2 +fi + +binary=$(mktemp /tmp/zagkit-test-button-gallery.XXXXXX) +cleanup() { rm -f -- "$binary"; } +trap cleanup EXIT + +"$znc" "$root/tests/button_gallery_contract.zag" --no-zagd \ + --analyze-strict --no-foreground-cache -o "$binary" +"$binary" "$font" diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 7b4bc43..182116a 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -38,6 +38,17 @@ cd "$root" "$tmp/design-tokens-contract" "$znc" tests/button_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/button-contract" "$tmp/button-contract" +if ! command -v fc-match >/dev/null 2>&1; then + printf 'headless test: FAIL: Button gallery requires fontconfig fc-match\n' >&2 + exit 1 +fi +gallery_font=$(fc-match -f '%{file}\n' 'Noto Sans' | sed -n '1p') +if [ -z "$gallery_font" ] || [ ! -f "$gallery_font" ]; then + printf 'headless test: FAIL: Button gallery could not resolve a system font\n' >&2 + exit 1 +fi +"$znc" tests/button_gallery_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/button-gallery-contract" +"$tmp/button-gallery-contract" "$gallery_font" "$znc" tests/canvas_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/canvas-contract" "$tmp/canvas-contract" "$znc" tests/measure_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/measure-contract" @@ -98,4 +109,4 @@ printf 'Linux preview CPU snapshot contract: pass=2 fail=0\n' "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (backend truth, state, reconciliation, intrinsic measurement, constraints, Flex, shared Button state/render/hit/semantics/Talkback, retained Canvas immediate drawing, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, Unicode grapheme segmentation, positioned glyph-run transport, collection semantics, Talkback, native-preview semantics, owned render resources, canonical paths and images, bounded PNG decode, display lists, analytic rounded geometry, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion)\n' +printf 'headless test: PASS (backend truth, state, reconciliation, intrinsic measurement, constraints, Flex, shared Button state/render/hit/semantics/Talkback plus its executable conformance gallery, retained Canvas immediate drawing, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, Unicode grapheme segmentation, positioned glyph-run transport, collection semantics, Talkback, native-preview semantics, owned render resources, canonical paths and images, bounded PNG decode, display lists, analytic rounded geometry, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion)\n' From b39050626dd35b59ee506662b1a41848869ea98e Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 04:52:35 -0700 Subject: [PATCH 087/103] Require honest Zag runtime resource contracts --- GOAL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/GOAL.md b/GOAL.md index 2fde43f..713a417 100644 --- a/GOAL.md +++ b/GOAL.md @@ -37,6 +37,8 @@ more precision is needed and preserve its original ID as the parent. - [ ] `G1-JNI` Implement typed JNI ABI seams. — Exit: calls, references, exceptions, threads, callbacks, and cleanup pass on Android - [ ] `G1-CALLBACKS` Complete general foreign callback ABI support. — Exit: register, stack, lifetime, reentrancy, and negative suites pass per target - [ ] `G1-AGGREGATES` Complete foreign aggregate argument and return support. — Exit: structs, unions, vectors, floats, alignment, and register-class suites pass per target +- [ ] `G1-FFI-LIFETIMES` Implement exact per-parameter foreign lifetime contracts in Zag without treating nonretaining inputs as borrowed returns. — Exit: multi-pointer immutable/mutable input, consume, computed/null input, return-escape, formatter, manifest, and native C ABI suites pass with legacy contracts remaining compatible +- [ ] `G1-RUNTIME-RESOURCES` Implement move-capable runtime resource aggregates, exact owned-field cleanup, and explicit foreign acquire/consume transfer in Zag. — Exit: construct, partial initialization, move-return, deinit, field release, null/error cleanup, double-use, copy, leak, and foreign-handle suites pass before the Linux shell stores owned buffers or handles in returned aggregates - [ ] `G1-RESOURCES` Implement deterministic compiler-owned resource embedding. — Exit: binary, empty, relative, malformed, cache-identity, reproducibility, and target suites pass - [ ] `G1-DYNAMIC-LOAD` Complete typed cross-platform dynamic loading. — Exit: lookup, version failure, ownership, unload, callback, and aggregate suites pass - [ ] `G1-CONCURRENCY` Complete main-loop and worker primitives with a documented memory model. — Exit: wakeup, cancellation, affinity, race, shutdown, and platform suites pass From a43a06d233499e15befca1d20e854680ae102146 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 05:00:25 -0700 Subject: [PATCH 088/103] Require truthful showcase navigation --- docs/design/showcase-conformance.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/design/showcase-conformance.md b/docs/design/showcase-conformance.md index f596121..4a0c4ed 100644 --- a/docs/design/showcase-conformance.md +++ b/docs/design/showcase-conformance.md @@ -60,6 +60,11 @@ but it must be labeled experimental and cannot promote the visual system. focused states, arrow-key behavior, disabled behavior, and tab semantics. - Navigation exposes one canonical active location. Contextual inspectors and system status are labeled and styled as their actual roles. +- Every enabled navigation item activates its named destination: the selected + label, main landmark, visible heading, semantics, actions, and Talkback tree + must describe the same content. An item whose destination is not implemented + remains visibly and semantically disabled; changing only its highlight is a + failing no-op, not navigation evidence. ## Evidence matrix From 772160b5c4c27382496ae5bba5583f0afefbdfb2 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 05:17:34 -0700 Subject: [PATCH 089/103] Add accessible performance chart contract --- contracts/components.json | 1 + docs/components/performance-chart.md | 133 +++ docs/evidence/performance-chart-2026-08-09.md | 38 + src/components/performance_chart.zag | 1051 +++++++++++++++++ tests/performance_chart_contract.zag | 382 ++++++ tools/test-headless.sh | 4 +- tools/test-performance-chart.sh | 18 + 7 files changed, 1626 insertions(+), 1 deletion(-) create mode 100644 docs/components/performance-chart.md create mode 100644 docs/evidence/performance-chart-2026-08-09.md create mode 100644 src/components/performance_chart.zag create mode 100644 tests/performance_chart_contract.zag create mode 100755 tools/test-performance-chart.sh diff --git a/contracts/components.json b/contracts/components.json index 8e590e7..624a5c9 100644 --- a/contracts/components.json +++ b/contracts/components.json @@ -68,6 +68,7 @@ {"id":"scroll","family":"layout","name":"Scroll","milestone":2,"status":"planned","semantic_roles":["group"],"inputs":["pointer","keyboard","touch","pen","wheel","gamepad"],"adaptive":["density","platform","direction","reduced_motion"]}, {"id":"safe-area","family":"layout","name":"SafeArea","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["platform","orientation"]}, {"id":"canvas","family":"advanced","name":"Canvas","milestone":2,"status":"implementing","semantic_roles":["group"],"inputs":["pointer","keyboard","touch","pen"],"adaptive":["density","contrast","direction"],"source":"src/components/canvas.zag","evidence":"tests/canvas_contract.zag","documentation":"docs/architecture/canvas.md"}, + {"id":"performance-chart","family":"advanced","name":"PerformanceChart","milestone":2,"status":"implementing","semantic_roles":["group","text","button","table","row","cell"],"inputs":["pointer","keyboard","touch"],"adaptive":["density","contrast","text_scale"],"source":"src/components/performance_chart.zag","evidence":"tests/performance_chart_contract.zag","documentation":"docs/components/performance-chart.md"}, {"id":"viewport","family":"advanced","name":"Viewport","milestone":4,"status":"planned","semantic_roles":["application","group"],"inputs":["pointer","keyboard","touch","pen","wheel","gamepad","gesture"],"adaptive":["density","platform","contrast","reduced_motion"]}, {"id":"command-palette","family":"advanced","name":"CommandPalette","milestone":4,"status":"planned","semantic_roles":["dialog","searchbox","listbox"],"inputs":["pointer","keyboard","touch"],"adaptive":["density","platform","locale","direction","text_scale"]} ] diff --git a/docs/components/performance-chart.md b/docs/components/performance-chart.md new file mode 100644 index 0000000..5382278 --- /dev/null +++ b/docs/components/performance-chart.md @@ -0,0 +1,133 @@ +# Experimental performance chart + +`PerformanceChart` is one bounded line/scatter component for frame-performance +evidence. It proves chart anatomy, semantic equivalence, token routing, focus +state, and stable-ID automation. It is not a general chart suite and is not a +production visual-polish claim. + +## Scope and input contract + +The component accepts one `PerformanceChartSpec` with: + +- a positive stable chart `NodeKey` and bounds of at least 640 by 420 logical + pixels; +- a title and description; +- independently named x and y axes with explicit units; +- bounded integer domains, two to twelve ticks per axis, and one caller-owned + label for every tick; +- named baseline and deadline values inside the y domain; and +- one to eight named series, each containing two to sixty-four aligned, + strictly increasing samples. + +Every sample carries its numeric x/y coordinates and its already-formatted +accessible x/y strings. Series in one chart must have the same sample count and +the same x coordinate at each row. This makes the semantic data table exact +instead of asking assistive technology to reverse-engineer pixels. + +Names, descriptions, units, tick labels, and cell strings must be valid, +NUL-free UTF-8 within the component bounds. Series IDs must be positive and +unique. Coordinates are bounded to plus or minus one million before fixed-point +mapping, preventing unchecked multiplication. Duplicate IDs, malformed text, +misaligned samples, out-of-range references, excessive series, and malformed +state fail before display, hit, or semantic mutation. + +## Visual anatomy + +The retained Canvas emits: + +- raised panel, border, shadow, and recessed plot surfaces; +- independently inspectable grid and axis paints; +- ticks on both axes; +- a named baseline reference and a distinct semantic-error deadline reference; +- one filled line ribbon plus one anti-aliased rounded scatter mark per sample; +- named series swatches arranged with Flex; and +- separate selected, focused, available, and disabled legend treatments. + +All colors route through semantic roles such as `color.chart.grid`, +`color.chart.axis`, `color.chart.series.primary`, `color.focus`, and +`color.status.error`. Panel depth routes through the raised elevation and panel +radius tokens. Repeated padding, legend gaps, and label spacing resolve through +named Flex spacing tokens. + +The implementation uses the public Canvas resource path for each line. A line +is an immutable bounded path of filled segment ribbons because the current CPU +oracle implements path fills but not path strokes. Scatter marks and all other +chrome use retained display-list primitives. The complete Canvas seals and +verifies before its semantic and hit contributions become available. + +## Required typography composition + +The current shared component architecture does not let Canvas fabricate text. +Like Button, the chart returns caller-owned content placement rather than +drawing placeholder bars. `PerformanceChartArtifact.text_slots` owns the exact +UTF-8 text plus bounds and semantic token provenance for every required label: + +- title; +- x-axis name and unit; +- y-axis name and unit; +- every x tick and every y tick; +- every legend series name; +- baseline label; and +- deadline label. + +Each slot carries a stable semantic `NodeKey`, `SemanticTypeToken`, and +`SemanticColorToken`. The host must compose a real Zagkit Text child into every +slot. A showcase, screenshot, or native surface is incomplete if any slot is +not rendered. Semantics do not excuse missing visible chart labels. + +The focused component test validates slot completeness, stable IDs, positive +bounds, type roles, and color roles. It intentionally rasterizes only the +Canvas-owned chart geometry. Therefore its CPU surface hash is renderer evidence +for the chart chrome, line, scatter, grid, references, legend materials, and +focus ring—not evidence that a native integration rendered the Text children. +The component remains experimental until a Text-composed showcase and native +accessibility execution cover this boundary. + +## Semantics and data equivalence + +The Canvas contributes one named group. Its children provide: + +- title, axis-name, axis-unit, tick, baseline, and deadline text nodes with + exact logical bounds; +- a named legend group whose series buttons expose description, selected, + disabled, focus order, focus action, and activation action; +- a table with `(sample count + 1)` rows and `(series count + 1)` columns; +- one header row containing the x axis and every named series; and +- one data row per sample with exact row/column coordinates and formatted + values. + +The table is the accessible equivalent of the chart, not a summary. It uses +the same caller-provided names, units, and formatted values as the visible +composition slots. + +## Stable IDs and Talkback + +The chart ID is caller-owned. Series IDs are caller-owned stable Talkback +targets. Axis, unit, tick, reference, table, row, and cell IDs derive +deterministically from the chart ID through `semantic_generated_key` and public +component ID helpers. + +Enabled legend entries expose activate and focus actions; disabled entries are +discoverable but expose neither. Talkback can query every table cell by ID and +receives its exact role, row, column, and owned-text evidence. Pixel fallback +remains disabled in the focused contract. + +## Current evidence and limits + +Run: + +```sh +./tools/test-performance-chart.sh +``` + +The contract builds the same chart twice, verifies sealed Canvas identity, +Flex-derived geometry, token roles, complete text slots, semantic table +coordinates, stable-ID Talkback actions, fail-closed duplicate IDs, and +pixel-identical CPU output. + +This slice does not provide arbitrary chart types, logarithmic/time scales, +locale formatting, axis collision avoidance, zoom, pan, tooltip interaction, +large-data decimation, RTL chart policy, high-contrast adaptation, native +accessibility adapters, GPU comparison, or production typography composition. +Those remain separate work. It also does not select RFC 0007's final visual +direction. diff --git a/docs/evidence/performance-chart-2026-08-09.md b/docs/evidence/performance-chart-2026-08-09.md new file mode 100644 index 0000000..02e21b3 --- /dev/null +++ b/docs/evidence/performance-chart-2026-08-09.md @@ -0,0 +1,38 @@ +# Experimental performance-chart checkpoint — 2026-08-09 + +This checkpoint records one bounded component contract. It does not claim a +general chart suite, native accessibility, a selected visual direction, or +production polish. + +## Focused result + +- Strict executable contract: `Performance chart contract: pass=18 fail=0`. +- Retained evidence hash: `1042463816`. +- Deterministic Canvas-only CPU surface hash: `1813314353`. +- The same spec rebuilt with identical display-list, semantic, typography-slot, + hit-tree, and CPU identity. + +## Proved surface + +The executable scene contains three named four-point performance series, seven +ticks per axis, an 8 ms baseline, a 16 ms deadline, line ribbons, scatter +marks, a Flex-positioned legend, selected/focused state, available state, and +disabled state. Semantic tokens own every color, radius, elevation, type, and +spacing role. + +The parallel semantic tree names both axes and units, references, all ticks, +all series, and a 5 by 4 data table. Talkback activates and focuses the selected +series at stable ID `7100`, rejects disabled series `7102`, queries an exact +table cell by derived stable ID, and keeps pixel fallback disabled. + +Twenty-four owned typography slots cover the title, four axis name/unit roles, +fourteen tick labels, three legend names, baseline, and deadline. Each slot +records exact bounds, stable semantic ID, type token, and color token. + +## Honest boundary + +The CPU hash covers Canvas-owned visual geometry only. The current component +does not itself draw glyphs; a host must render every text slot through Zagkit +Text. Consequently this checkpoint does not include a screenshot and does not +answer native/showcase typography polish. A later composed showcase must render +all twenty-four slots before it can serve as visual chart evidence. diff --git a/src/components/performance_chart.zag b/src/components/performance_chart.zag new file mode 100644 index 0000000..fb43a44 --- /dev/null +++ b/src/components/performance_chart.zag @@ -0,0 +1,1051 @@ +@import("std:list") +@import("canvas.zag") +@import("../design/tokens.zag") +@import("../layout/flex.zag") +@import("../layout/adaptive.zag") + +// Experimental bounded line/scatter chart for frame-performance evidence. +// This is one accessible component contract, not a general chart suite. + +enum PerformanceChartError { + none, + invalid_spec, + invalid_series, + invalid_point, + flex_error, + canvas_error, + path_error, + semantics_error, + hit_error, +} + +struct PerformanceChartPoint { + x: i64, + y: i64, + x_value: []u8, + y_value: []u8, +} + +struct PerformanceChartSeries { + id: NodeKey, + name: []u8, + description: []u8, + points: ArrayList[PerformanceChartPoint], + color_token: SemanticColorToken, + selected: i32, + focused: i32, + disabled: i32, +} + +struct PerformanceChartSpec { + id: NodeKey, + bounds: Rect, + title: []u8, + description: []u8, + x_axis_name: []u8, + x_axis_unit: []u8, + y_axis_name: []u8, + y_axis_unit: []u8, + x_min: i64, + x_max: i64, + y_min: i64, + y_max: i64, + x_tick_count: i32, + y_tick_count: i32, + baseline_value: i64, + baseline_name: []u8, + deadline_value: i64, + deadline_name: []u8, + x_tick_labels: ArrayList[[]u8], + y_tick_labels: ArrayList[[]u8], + series: ArrayList[PerformanceChartSeries], +} + +enum PerformanceChartTextSlotKind { + title, + x_axis_name, + x_axis_unit, + y_axis_name, + y_axis_unit, + x_tick, + y_tick, + legend_name, + baseline, + deadline, +} + +struct PerformanceChartTextSlot { + id: NodeKey, + kind: PerformanceChartTextSlotKind, + text: ArrayList[u8], + bounds: Rect, + type_token: SemanticTypeToken, + color_token: SemanticColorToken, +} + +struct PerformanceChartMetrics { + outer_padding: i64, + title_height: i64, + legend_gap: i64, + legend_height: i64, + axis_label_extent: i64, + plot_bounds: Rect, + title_bounds: Rect, + legend_bounds: Rect, + x_axis_label_bounds: Rect, + y_axis_label_bounds: Rect, +} + +struct PerformanceChartLegendArtifact { + id: NodeKey, + bounds: Rect, + selected: i32, + focused: i32, + disabled: i32, + color_token: SemanticColorToken, +} + +struct PerformanceChartArtifact { + canvas: Canvas, + semantics: SemanticsTree, + hits: HitTree, + legend: ArrayList[PerformanceChartLegendArtifact], + text_slots: ArrayList[PerformanceChartTextSlot], + metrics: PerformanceChartMetrics, + error: PerformanceChartError, + canvas_error: CanvasError, + path_error: PathError, + semantics_error: SemanticsError, + hit_error: HitTreeError, + table_rows: i64, + table_columns: i64, + evidence_hash: i64, +} + +fn performance_chart_point(x: i64, y: i64, + x_value: []u8, y_value: []u8) PerformanceChartPoint { + return PerformanceChartPoint{ + .x = x, .y = y, .x_value = x_value, .y_value = y_value, + }; +} + +fn performance_chart_series(id: NodeKey, name: []u8, + points: ArrayList[PerformanceChartPoint], + color_token: SemanticColorToken) PerformanceChartSeries { + return PerformanceChartSeries{ + .id = id, .name = name, .description = "", .points = points, + .color_token = color_token, .selected = 0, .focused = 0, .disabled = 0, + }; +} + +fn performance_chart_spec(id: NodeKey, bounds: Rect, + series: ArrayList[PerformanceChartSeries], + x_tick_labels: ArrayList[[]u8], + y_tick_labels: ArrayList[[]u8]) PerformanceChartSpec { + return PerformanceChartSpec{ + .id = id, .bounds = bounds, + .title = "Frame pacing", .description = "Experimental performance chart", + .x_axis_name = "Elapsed time", .x_axis_unit = "seconds", + .y_axis_name = "Frame time", .y_axis_unit = "milliseconds", + .x_min = 0, .x_max = 1, .y_min = 0, .y_max = 1, + .x_tick_count = 5, .y_tick_count = 5, + .baseline_value = 0, .baseline_name = "Target baseline", + .deadline_value = 1, .deadline_name = "Display deadline", + .x_tick_labels = x_tick_labels, + .y_tick_labels = y_tick_labels, + .series = series, + }; +} + +fn performance_chart_title_id(id: NodeKey) NodeKey { + return semantic_generated_key(id, SemanticRole.text, 1); +} + +fn performance_chart_x_axis_id(id: NodeKey) NodeKey { + return semantic_generated_key(id, SemanticRole.text, 2); +} + +fn performance_chart_y_axis_id(id: NodeKey) NodeKey { + return semantic_generated_key(id, SemanticRole.text, 3); +} + +fn performance_chart_baseline_id(id: NodeKey) NodeKey { + return semantic_generated_key(id, SemanticRole.text, 4); +} + +fn performance_chart_deadline_id(id: NodeKey) NodeKey { + return semantic_generated_key(id, SemanticRole.text, 5); +} + +fn performance_chart_legend_id(id: NodeKey) NodeKey { + return semantic_generated_key(id, SemanticRole.group, 6); +} + +fn performance_chart_table_id(id: NodeKey) NodeKey { + return semantic_generated_key(id, SemanticRole.table, 7); +} + +fn performance_chart_x_axis_unit_id(id: NodeKey) NodeKey { + return semantic_generated_key(id, SemanticRole.text, 8); +} + +fn performance_chart_y_axis_unit_id(id: NodeKey) NodeKey { + return semantic_generated_key(id, SemanticRole.text, 9); +} + +fn performance_chart_x_tick_id(id: NodeKey, index: i32) NodeKey { + return semantic_generated_key(id, SemanticRole.text, 100 + index as i64); +} + +fn performance_chart_y_tick_id(id: NodeKey, index: i32) NodeKey { + return semantic_generated_key(id, SemanticRole.text, 200 + index as i64); +} + +fn performance_chart_table_row_id(id: NodeKey, row: i64) NodeKey { + return semantic_generated_key(performance_chart_table_id(id), + SemanticRole.row, 100 + row); +} + +fn performance_chart_table_cell_id(id: NodeKey, + row: i64, column: i64) NodeKey { + return semantic_generated_key(performance_chart_table_row_id(id, row), + SemanticRole.cell, 1000 + column); +} + +fn performance_chart_color_valid(token: SemanticColorToken) i32 { + return (token == SemanticColorToken.chart_series_primary || + token == SemanticColorToken.chart_series_secondary || + token == SemanticColorToken.chart_series_tertiary) as i32; +} + +fn performance_chart_text_valid(text: []u8) i32 { + return (text.len > 0 && text.len <= 4096 && + semantic_text_input_valid(text) != 0) as i32; +} + +fn performance_chart_metrics(bounds: Rect) PerformanceChartMetrics { + let density: FlexDensity = FlexDensity.standard; + let outer: i64 = flex_spacing(FlexSpacingToken.xlarge, density); + let title_height: i64 = flex_spacing(FlexSpacingToken.xxlarge, density); + let legend_gap: i64 = flex_spacing(FlexSpacingToken.small, density); + let legend_height: i64 = flex_spacing(FlexSpacingToken.xxlarge, density) + + flex_spacing(FlexSpacingToken.small, density); + let axis_extent: i64 = flex_spacing(FlexSpacingToken.xxlarge, density) + + flex_spacing(FlexSpacingToken.xlarge, density); + let plot_left: i64 = bounds.x + outer + axis_extent; + let plot_top: i64 = bounds.y + outer + title_height + legend_height + legend_gap; + let plot_right: i64 = bounds.x + bounds.width - outer; + let plot_bottom: i64 = bounds.y + bounds.height - outer - axis_extent; + return PerformanceChartMetrics{ + .outer_padding = outer, + .title_height = title_height, + .legend_gap = legend_gap, + .legend_height = legend_height, + .axis_label_extent = axis_extent, + .plot_bounds = rect(plot_left, plot_top, + plot_right - plot_left, plot_bottom - plot_top), + .title_bounds = rect(bounds.x + outer, bounds.y + outer, + bounds.width - outer * 2, title_height), + .legend_bounds = rect(plot_left, + bounds.y + outer + title_height, + plot_right - plot_left, legend_height), + .x_axis_label_bounds = rect(plot_left, plot_bottom, + plot_right - plot_left, axis_extent), + .y_axis_label_bounds = rect(bounds.x + outer, plot_top, + axis_extent, plot_bottom - plot_top), + }; +} + +fn performance_chart_spec_valid(spec: PerformanceChartSpec) PerformanceChartError { + let scale: i64 = unit_scale(); + if (spec.id.value <= 0 || spec.bounds.x < 0 || spec.bounds.y < 0 || + spec.bounds.width < 640 * scale || spec.bounds.height < 420 * scale || + spec.bounds.x + spec.bounds.width > unbounded_extent() || + spec.bounds.y + spec.bounds.height > unbounded_extent() || + performance_chart_text_valid(spec.title) == 0 || + performance_chart_text_valid(spec.description) == 0 || + performance_chart_text_valid(spec.x_axis_name) == 0 || + performance_chart_text_valid(spec.x_axis_unit) == 0 || + performance_chart_text_valid(spec.y_axis_name) == 0 || + performance_chart_text_valid(spec.y_axis_unit) == 0 || + performance_chart_text_valid(spec.baseline_name) == 0 || + performance_chart_text_valid(spec.deadline_name) == 0 || + spec.x_min < -1000000 || spec.x_max > 1000000 || + spec.y_min < -1000000 || spec.y_max > 1000000 || + spec.x_min >= spec.x_max || spec.y_min >= spec.y_max || + spec.x_tick_count < 2 || spec.x_tick_count > 12 || + spec.y_tick_count < 2 || spec.y_tick_count > 12 || + spec.x_tick_labels.len != spec.x_tick_count || + spec.y_tick_labels.len != spec.y_tick_count || + spec.baseline_value < spec.y_min || spec.baseline_value > spec.y_max || + spec.deadline_value < spec.y_min || spec.deadline_value > spec.y_max || + spec.series.len <= 0 || spec.series.len > 8) { + return PerformanceChartError.invalid_spec; + } + let tick_index: i32 = 0; + while (tick_index < spec.x_tick_labels.len) { + if (performance_chart_text_valid(spec.x_tick_labels.data[tick_index]) == 0) { + return PerformanceChartError.invalid_spec; + } + tick_index = tick_index + 1; + } + tick_index = 0; + while (tick_index < spec.y_tick_labels.len) { + if (performance_chart_text_valid(spec.y_tick_labels.data[tick_index]) == 0) { + return PerformanceChartError.invalid_spec; + } + tick_index = tick_index + 1; + } + let point_count: i32 = spec.series.data[0].points.len; + if (point_count < 2 || point_count > 64) { + return PerformanceChartError.invalid_series; + } + let selected_count: i32 = 0; + let focused_count: i32 = 0; + let i: i32 = 0; + while (i < spec.series.len) { + let series: PerformanceChartSeries = spec.series.data[i]; + if (series.id.value <= 0 || node_key_equal(series.id, spec.id) != 0 || + performance_chart_text_valid(series.name) == 0 || + (series.description.len > 0 && + performance_chart_text_valid(series.description) == 0) || + series.points.len != point_count || + performance_chart_color_valid(series.color_token) == 0 || + (series.selected != 0 && series.selected != 1) || + (series.focused != 0 && series.focused != 1) || + (series.disabled != 0 && series.disabled != 1) || + (series.focused != 0 && series.disabled != 0)) { + return PerformanceChartError.invalid_series; + } + if (series.selected != 0) { selected_count = selected_count + 1; } + if (series.focused != 0) { focused_count = focused_count + 1; } + let duplicate: i32 = i + 1; + while (duplicate < spec.series.len) { + if (node_key_equal(series.id, spec.series.data[duplicate].id) != 0) { + return PerformanceChartError.invalid_series; + } + duplicate = duplicate + 1; + } + let point_index: i32 = 0; + while (point_index < series.points.len) { + let point: PerformanceChartPoint = series.points.data[point_index]; + if (point.x < spec.x_min || point.x > spec.x_max || + point.y < spec.y_min || point.y > spec.y_max || + performance_chart_text_valid(point.x_value) == 0 || + performance_chart_text_valid(point.y_value) == 0 || + (point_index > 0 && point.x <= + series.points.data[point_index - 1].x) || + (i > 0 && point.x != spec.series.data[0].points.data[point_index].x)) { + return PerformanceChartError.invalid_point; + } + point_index = point_index + 1; + } + i = i + 1; + } + if (selected_count > 1 || focused_count > 1) { + return PerformanceChartError.invalid_series; + } + let metrics: PerformanceChartMetrics = performance_chart_metrics(spec.bounds); + if (metrics.plot_bounds.width <= 0 || metrics.plot_bounds.height <= 0) { + return PerformanceChartError.invalid_spec; + } + return PerformanceChartError.none; +} + +fn performance_chart_empty(spec: PerformanceChartSpec) PerformanceChartArtifact { + let canvas_specification: CanvasSpec = canvas_spec(spec.id, spec.bounds, + CanvasSemanticsPolicy.named_group, spec.title); + canvas_specification.description = spec.description; + canvas_specification.hit_testable = 1; + let limits: CanvasLimits = canvas_limits_default(); + limits.max_operations = 4096; + limits.max_resources = 16; + limits.max_total_resource_bytes = 1048576; + limits.max_resource_bytes = 262144; + limits.max_destination_operations = 8192; + return PerformanceChartArtifact{ + .canvas = canvas_make(canvas_specification, limits), + .semantics = semantics_tree_make(), + .hits = hit_tree_make(), + .legend = make[PerformanceChartLegendArtifact](spec.series.len), + .text_slots = make[PerformanceChartTextSlot](32), + .metrics = performance_chart_metrics(spec.bounds), + .error = PerformanceChartError.none, + .canvas_error = CanvasError.none, + .path_error = PathError.none, + .semantics_error = SemanticsError.none, + .hit_error = HitTreeError.none, + .table_rows = 0, + .table_columns = 0, + .evidence_hash = 0, + }; +} + +fn performance_chart_free(artifact: *PerformanceChartArtifact) void { + let text_index: i32 = 0; + while (text_index < artifact.*.text_slots.len) { + free[u8](&artifact.*.text_slots.data[text_index].text); + text_index = text_index + 1; + } + free[PerformanceChartTextSlot](&artifact.*.text_slots); + free[PerformanceChartLegendArtifact](&artifact.*.legend); + hit_tree_free(&artifact.*.hits); + semantics_tree_free(&artifact.*.semantics); + _ = canvas_free(&artifact.*.canvas); +} + +fn performance_chart_copy_text(text: []u8) ArrayList[u8] { + let owned: ArrayList[u8] = make[u8](text.len); + let i: i32 = 0; + while (i < text.len) { + push[u8](&owned, text[i]); i = i + 1; + } + return owned; +} + +fn performance_chart_add_text_slot(artifact: *PerformanceChartArtifact, + id: NodeKey, kind: PerformanceChartTextSlotKind, text: []u8, + bounds: Rect, type_token: SemanticTypeToken, + color_token: SemanticColorToken) void { + push[PerformanceChartTextSlot](&artifact.*.text_slots, + PerformanceChartTextSlot{ + .id = id, .kind = kind, + .text = performance_chart_copy_text(text), + .bounds = bounds, .type_token = type_token, + .color_token = color_token, + }); +} + +fn performance_chart_fail_canvas(artifact: *PerformanceChartArtifact, + error: CanvasError) i32 { + artifact.*.error = PerformanceChartError.canvas_error; + artifact.*.canvas_error = error; + return 0; +} + +fn performance_chart_push_rect(artifact: *PerformanceChartArtifact, + bounds: Rect, paint: Paint) i32 { + let op: DisplayOp = canvas_op(artifact.*.canvas, DisplayOpKind.fill_rect); + op.bounds = bounds; op.paint = paint; + let error: CanvasError = canvas_push(&artifact.*.canvas, op); + if (error != CanvasError.none) { + return performance_chart_fail_canvas(artifact, error); + } + return 1; +} + +fn performance_chart_push_round(artifact: *PerformanceChartArtifact, + bounds: Rect, radius: i64, paint: Paint) i32 { + let op: DisplayOp = canvas_op(artifact.*.canvas, + DisplayOpKind.fill_rounded_rect); + op.bounds = bounds; op.parameter = radius; op.paint = paint; + let error: CanvasError = canvas_push(&artifact.*.canvas, op); + if (error != CanvasError.none) { + return performance_chart_fail_canvas(artifact, error); + } + return 1; +} + +fn performance_chart_map_x(spec: PerformanceChartSpec, + metrics: PerformanceChartMetrics, value: i64) i64 { + return metrics.plot_bounds.x + + ((value - spec.x_min) * metrics.plot_bounds.width) / + (spec.x_max - spec.x_min); +} + +fn performance_chart_map_y(spec: PerformanceChartSpec, + metrics: PerformanceChartMetrics, value: i64) i64 { + return metrics.plot_bounds.y + metrics.plot_bounds.height - + ((value - spec.y_min) * metrics.plot_bounds.height) / + (spec.y_max - spec.y_min); +} + +fn performance_chart_background(artifact: *PerformanceChartArtifact, + spec: PerformanceChartSpec) i32 { + let scale: i64 = unit_scale(); + let elevation: ResolvedElevationToken = + semantic_elevation(SemanticElevationToken.raised); + let spread: i64 = elevation.shadow_spread; + if (performance_chart_push_round(artifact, + rect(spec.bounds.x - spread, + spec.bounds.y + elevation.shadow_offset_y - spread, + spec.bounds.width + spread * 2, + spec.bounds.height + spread * 2), + semantic_radius(SemanticRadiusToken.panel) + spread, + semantic_color_with_alpha(SemanticColorToken.shadow, + elevation.shadow_alpha)) == 0) { return 0; } + if (performance_chart_push_round(artifact, spec.bounds, + semantic_radius(SemanticRadiusToken.panel), + semantic_color_paint(SemanticColorToken.border_emphasis)) == 0) { return 0; } + if (performance_chart_push_round(artifact, + rect(spec.bounds.x + scale, spec.bounds.y + scale, + spec.bounds.width - scale * 2, spec.bounds.height - scale * 2), + semantic_radius(SemanticRadiusToken.panel) - scale, + semantic_color_paint(SemanticColorToken.surface_raised)) == 0) { return 0; } + return performance_chart_push_round(artifact, artifact.*.metrics.plot_bounds, + semantic_radius(SemanticRadiusToken.card), + semantic_color_paint(SemanticColorToken.surface_inset)); +} + +fn performance_chart_grid(artifact: *PerformanceChartArtifact, + spec: PerformanceChartSpec) i32 { + let scale: i64 = unit_scale(); + let plot: Rect = artifact.*.metrics.plot_bounds; + let i: i32 = 0; + while (i < spec.x_tick_count) { + let x: i64 = plot.x + + (plot.width * (i as i64)) / ((spec.x_tick_count - 1) as i64); + if (performance_chart_push_rect(artifact, + rect(x, plot.y, scale, plot.height), + semantic_color_paint(SemanticColorToken.chart_grid)) == 0) { return 0; } + if (performance_chart_push_rect(artifact, + rect(x - scale, plot.y + plot.height, scale * 2, 6 * scale), + semantic_color_paint(SemanticColorToken.chart_axis)) == 0) { return 0; } + i = i + 1; + } + i = 0; + while (i < spec.y_tick_count) { + let y: i64 = plot.y + + (plot.height * (i as i64)) / ((spec.y_tick_count - 1) as i64); + if (performance_chart_push_rect(artifact, + rect(plot.x, y, plot.width, scale), + semantic_color_paint(SemanticColorToken.chart_grid)) == 0) { return 0; } + if (performance_chart_push_rect(artifact, + rect(plot.x - 6 * scale, y - scale, 6 * scale, scale * 2), + semantic_color_paint(SemanticColorToken.chart_axis)) == 0) { return 0; } + i = i + 1; + } + if (performance_chart_push_rect(artifact, + rect(plot.x, plot.y + plot.height - 2 * scale, + plot.width, 2 * scale), + semantic_color_paint(SemanticColorToken.chart_axis)) == 0) { return 0; } + if (performance_chart_push_rect(artifact, + rect(plot.x, plot.y, 2 * scale, plot.height), + semantic_color_paint(SemanticColorToken.chart_axis)) == 0) { return 0; } + let baseline_y: i64 = performance_chart_map_y( + spec, artifact.*.metrics, spec.baseline_value); + if (performance_chart_push_rect(artifact, + rect(plot.x, baseline_y, plot.width, scale), + semantic_color_with_alpha(SemanticColorToken.chart_axis, 52000)) == 0) { + return 0; + } + let deadline_y: i64 = performance_chart_map_y( + spec, artifact.*.metrics, spec.deadline_value); + return performance_chart_push_rect(artifact, + rect(plot.x, deadline_y - scale, plot.width, 2 * scale), + semantic_color_paint(SemanticColorToken.status_error)); +} + +fn performance_chart_line_path(artifact: *PerformanceChartArtifact, + spec: PerformanceChartSpec, series: PerformanceChartSeries, + index: i32) i32 { + let scale: i64 = unit_scale(); + let half: i64 = scale; + if (series.selected != 0) { half = 2 * scale; } + let path: PathData = path_make(PathFillRule.non_zero); + let i: i32 = 1; + while (i < series.points.len) { + let first: PerformanceChartPoint = series.points.data[i - 1]; + let second: PerformanceChartPoint = series.points.data[i]; + let x1: i64 = performance_chart_map_x(spec, artifact.*.metrics, first.x); + let y1: i64 = performance_chart_map_y(spec, artifact.*.metrics, first.y); + let x2: i64 = performance_chart_map_x(spec, artifact.*.metrics, second.x); + let y2: i64 = performance_chart_map_y(spec, artifact.*.metrics, second.y); + if (path_move_to(&path, path_point(x1, y1 - half)) != PathError.none || + path_line_to(&path, path_point(x2, y2 - half)) != PathError.none || + path_line_to(&path, path_point(x2, y2 + half)) != PathError.none || + path_line_to(&path, path_point(x1, y1 + half)) != PathError.none || + path_close(&path) != PathError.none) { + artifact.*.error = PerformanceChartError.path_error; + artifact.*.path_error = path.last_error; + path_free(&path); return 0; + } + i = i + 1; + } + let sealed: PathError = path_seal(&path); + if (sealed != PathError.none) { + artifact.*.error = PerformanceChartError.path_error; + artifact.*.path_error = sealed; path_free(&path); return 0; + } + let encoded: PathEncodeResult = path_encode(path); + if (encoded.error != PathError.none) { + artifact.*.error = PerformanceChartError.path_error; + artifact.*.path_error = encoded.error; + path_encode_free(&encoded); path_free(&path); return 0; + } + let resource: CanvasResourceSpec = canvas_resource_spec(100 + index as i64, + RenderResourceKind.path, encoded.bytes.data[0..encoded.bytes.len], + path_resource_format_tag()); + let resource_error: CanvasError = canvas_add_resource(&artifact.*.canvas, resource); + if (resource_error != CanvasError.none) { + path_encode_free(&encoded); path_free(&path); + return performance_chart_fail_canvas(artifact, resource_error); + } + let draw: DisplayOp = canvas_resource_op(artifact.*.canvas, + DisplayOpKind.draw_path, 100 + index as i64); + let alpha: i64 = 56000; + if (series.selected != 0) { alpha = 65535; } + if (series.disabled != 0) { alpha = 18000; } + draw.paint = semantic_color_with_alpha(series.color_token, alpha); + let draw_error: CanvasError = canvas_push(&artifact.*.canvas, draw); + path_encode_free(&encoded); path_free(&path); + if (draw_error != CanvasError.none) { + return performance_chart_fail_canvas(artifact, draw_error); + } + return 1; +} + +fn performance_chart_series_marks(artifact: *PerformanceChartArtifact, + spec: PerformanceChartSpec, series: PerformanceChartSeries, + index: i32) i32 { + if (performance_chart_line_path(artifact, spec, series, index) == 0) { + return 0; + } + let radius: i64 = 4 * unit_scale(); + if (series.selected != 0) { radius = 6 * unit_scale(); } + let alpha: i64 = 62000; + if (series.disabled != 0) { alpha = 22000; } + let i: i32 = 0; + while (i < series.points.len) { + let point: PerformanceChartPoint = series.points.data[i]; + let x: i64 = performance_chart_map_x(spec, artifact.*.metrics, point.x); + let y: i64 = performance_chart_map_y(spec, artifact.*.metrics, point.y); + if (performance_chart_push_round(artifact, + rect(x - radius, y - radius, radius * 2, radius * 2), radius, + semantic_color_with_alpha(series.color_token, alpha)) == 0) { return 0; } + i = i + 1; + } + return 1; +} + +fn performance_chart_semantic_text(artifact: *PerformanceChartArtifact, + id: NodeKey, name: []u8, value: []u8, bounds: Rect) i32 { + let semantic: SemanticsSpec = semantics_spec(id, artifact.*.canvas.key, + SemanticRole.text, name); + semantic.value = value; semantic.has_bounds = 1; semantic.bounds = bounds; + let error: SemanticsError = semantics_add(&artifact.*.semantics, semantic); + if (error != SemanticsError.none) { + artifact.*.error = PerformanceChartError.semantics_error; + artifact.*.semantics_error = error; return 0; + } + return 1; +} + +fn performance_chart_add_static_semantics(artifact: *PerformanceChartArtifact, + spec: PerformanceChartSpec) i32 { + let scale: i64 = unit_scale(); + let caption_height: i64 = semantic_type_size(SemanticTypeToken.caption) + + flex_spacing(FlexSpacingToken.tiny, FlexDensity.standard); + let axis_half: i64 = artifact.*.metrics.axis_label_extent / 2; + if (performance_chart_semantic_text(artifact, + performance_chart_title_id(spec.id), spec.title, spec.description, + artifact.*.metrics.title_bounds) == 0) { return 0; } + performance_chart_add_text_slot(artifact, + performance_chart_title_id(spec.id), PerformanceChartTextSlotKind.title, + spec.title, artifact.*.metrics.title_bounds, + SemanticTypeToken.title, SemanticColorToken.text_primary); + let x_name_bounds: Rect = rect( + artifact.*.metrics.x_axis_label_bounds.x, + artifact.*.metrics.x_axis_label_bounds.y, + artifact.*.metrics.x_axis_label_bounds.width, axis_half); + let x_unit_bounds: Rect = rect(x_name_bounds.x, + x_name_bounds.y + axis_half, x_name_bounds.width, + artifact.*.metrics.x_axis_label_bounds.height - axis_half); + if (performance_chart_semantic_text(artifact, + performance_chart_x_axis_id(spec.id), spec.x_axis_name, spec.x_axis_unit, + x_name_bounds) == 0) { return 0; } + performance_chart_add_text_slot(artifact, + performance_chart_x_axis_id(spec.id), + PerformanceChartTextSlotKind.x_axis_name, spec.x_axis_name, + x_name_bounds, SemanticTypeToken.label, SemanticColorToken.text_primary); + if (performance_chart_semantic_text(artifact, + performance_chart_x_axis_unit_id(spec.id), spec.x_axis_unit, + "Horizontal axis unit", x_unit_bounds) == 0) { return 0; } + performance_chart_add_text_slot(artifact, + performance_chart_x_axis_unit_id(spec.id), + PerformanceChartTextSlotKind.x_axis_unit, spec.x_axis_unit, + x_unit_bounds, SemanticTypeToken.caption, SemanticColorToken.text_secondary); + let y_label_top: i64 = artifact.*.metrics.y_axis_label_bounds.y + + artifact.*.metrics.y_axis_label_bounds.height / 2 - axis_half; + let y_name_bounds: Rect = rect( + artifact.*.metrics.y_axis_label_bounds.x, y_label_top, + artifact.*.metrics.y_axis_label_bounds.width, axis_half); + let y_unit_bounds: Rect = rect(y_name_bounds.x, + y_name_bounds.y + axis_half, y_name_bounds.width, axis_half); + if (performance_chart_semantic_text(artifact, + performance_chart_y_axis_id(spec.id), spec.y_axis_name, spec.y_axis_unit, + y_name_bounds) == 0) { return 0; } + performance_chart_add_text_slot(artifact, + performance_chart_y_axis_id(spec.id), + PerformanceChartTextSlotKind.y_axis_name, spec.y_axis_name, + y_name_bounds, SemanticTypeToken.label, SemanticColorToken.text_primary); + if (performance_chart_semantic_text(artifact, + performance_chart_y_axis_unit_id(spec.id), spec.y_axis_unit, + "Vertical axis unit", y_unit_bounds) == 0) { return 0; } + performance_chart_add_text_slot(artifact, + performance_chart_y_axis_unit_id(spec.id), + PerformanceChartTextSlotKind.y_axis_unit, spec.y_axis_unit, + y_unit_bounds, SemanticTypeToken.caption, SemanticColorToken.text_secondary); + + let tick_index: i32 = 0; + while (tick_index < spec.x_tick_count) { + let x: i64 = artifact.*.metrics.plot_bounds.x + + (artifact.*.metrics.plot_bounds.width * (tick_index as i64)) / + ((spec.x_tick_count - 1) as i64); + let tick_width: i64 = 72 * scale; + let bounds: Rect = rect(x - tick_width / 2, + artifact.*.metrics.plot_bounds.y + artifact.*.metrics.plot_bounds.height + + flex_spacing(FlexSpacingToken.small, FlexDensity.standard), + tick_width, caption_height); + let id: NodeKey = performance_chart_x_tick_id(spec.id, tick_index); + if (performance_chart_semantic_text(artifact, id, + spec.x_tick_labels.data[tick_index], spec.x_axis_unit, bounds) == 0) { + return 0; + } + performance_chart_add_text_slot(artifact, id, + PerformanceChartTextSlotKind.x_tick, + spec.x_tick_labels.data[tick_index], bounds, + SemanticTypeToken.caption, SemanticColorToken.text_secondary); + tick_index = tick_index + 1; + } + tick_index = 0; + while (tick_index < spec.y_tick_count) { + let y: i64 = artifact.*.metrics.plot_bounds.y + + (artifact.*.metrics.plot_bounds.height * (tick_index as i64)) / + ((spec.y_tick_count - 1) as i64); + let bounds: Rect = rect( + artifact.*.metrics.y_axis_label_bounds.x, + y - caption_height / 2, + artifact.*.metrics.y_axis_label_bounds.width - + flex_spacing(FlexSpacingToken.small, FlexDensity.standard), + caption_height); + let id: NodeKey = performance_chart_y_tick_id(spec.id, tick_index); + if (performance_chart_semantic_text(artifact, id, + spec.y_tick_labels.data[tick_index], spec.y_axis_unit, bounds) == 0) { + return 0; + } + performance_chart_add_text_slot(artifact, id, + PerformanceChartTextSlotKind.y_tick, + spec.y_tick_labels.data[tick_index], bounds, + SemanticTypeToken.caption, SemanticColorToken.text_secondary); + tick_index = tick_index + 1; + } + let baseline_y: i64 = performance_chart_map_y( + spec, artifact.*.metrics, spec.baseline_value); + let reference_width: i64 = 220 * scale; + let baseline_label_y: i64 = baseline_y - caption_height; + if (baseline_label_y < artifact.*.metrics.plot_bounds.y) { + baseline_label_y = baseline_y + 2 * scale; + } + let baseline_bounds: Rect = rect( + artifact.*.metrics.plot_bounds.x + + flex_spacing(FlexSpacingToken.small, FlexDensity.standard), + baseline_label_y, reference_width, caption_height); + if (performance_chart_semantic_text(artifact, + performance_chart_baseline_id(spec.id), spec.baseline_name, + spec.y_axis_unit, baseline_bounds) == 0) { return 0; } + performance_chart_add_text_slot(artifact, + performance_chart_baseline_id(spec.id), + PerformanceChartTextSlotKind.baseline, spec.baseline_name, + baseline_bounds, SemanticTypeToken.caption, SemanticColorToken.text_secondary); + let deadline_y: i64 = performance_chart_map_y( + spec, artifact.*.metrics, spec.deadline_value); + let deadline_label_y: i64 = deadline_y - caption_height; + if (deadline_label_y < artifact.*.metrics.plot_bounds.y) { + deadline_label_y = deadline_y + 2 * scale; + } + let deadline_bounds: Rect = rect( + artifact.*.metrics.plot_bounds.x + artifact.*.metrics.plot_bounds.width - + reference_width - flex_spacing(FlexSpacingToken.small, + FlexDensity.standard), + deadline_label_y, reference_width, caption_height); + if (performance_chart_semantic_text(artifact, + performance_chart_deadline_id(spec.id), spec.deadline_name, + spec.y_axis_unit, deadline_bounds) == 0) { return 0; } + performance_chart_add_text_slot(artifact, + performance_chart_deadline_id(spec.id), + PerformanceChartTextSlotKind.deadline, spec.deadline_name, + deadline_bounds, SemanticTypeToken.caption, SemanticColorToken.status_error); + return 1; +} + +fn performance_chart_legend(artifact: *PerformanceChartArtifact, + spec: PerformanceChartSpec, visual_phase: i32) i32 { + if (visual_phase == 0) { + let legend_group: SemanticsSpec = semantics_spec( + performance_chart_legend_id(spec.id), artifact.*.canvas.key, + SemanticRole.group, "Series legend"); + legend_group.description = "Select the series emphasized in the chart"; + legend_group.has_bounds = 1; + legend_group.bounds = artifact.*.metrics.legend_bounds; + let semantic_error: SemanticsError = semantics_add( + &artifact.*.semantics, legend_group); + if (semantic_error != SemanticsError.none) { + artifact.*.error = PerformanceChartError.semantics_error; + artifact.*.semantics_error = semantic_error; return 0; + } + } + + let items: ArrayList[FlexItem] = make[FlexItem](spec.series.len); + let i: i32 = 0; + while (i < spec.series.len) { + let item: FlexItem = flex_item(100 + i as i64, + 160 * unit_scale(), artifact.*.metrics.legend_height); + item.grow = 1; item.shrink = 1; + item.min_main = 96 * unit_scale(); item.max_main = 260 * unit_scale(); + push[FlexItem](&items, item); + i = i + 1; + } + let style: FlexStyle = flex_style(FlexAxis.row, + flex_spacing(FlexSpacingToken.small, FlexDensity.standard), insets_all(0)); + let constraints: Constraints = tight_constraints( + artifact.*.metrics.legend_bounds.width, + artifact.*.metrics.legend_bounds.height); + let layout: FlexResult = flex_layout(items, constraints, style); + free[FlexItem](&items); + if (layout.error != FlexError.none || layout.placements.len != spec.series.len) { + flex_free(&layout); artifact.*.error = PerformanceChartError.flex_error; + return 0; + } + i = 0; + while (i < spec.series.len) { + let series: PerformanceChartSeries = spec.series.data[i]; + let local: Rect = layout.placements.data[i].frame; + let bounds: Rect = rect( + artifact.*.metrics.legend_bounds.x + local.x, + artifact.*.metrics.legend_bounds.y + local.y, + local.width, local.height); + let radius: i64 = semantic_radius(SemanticRadiusToken.control); + if (visual_phase != 0 && series.focused != 0 && series.disabled == 0) { + if (performance_chart_push_round(artifact, + rect(bounds.x - 2 * unit_scale(), bounds.y - 2 * unit_scale(), + bounds.width + 4 * unit_scale(), bounds.height + 4 * unit_scale()), + radius + 2 * unit_scale(), + semantic_color_with_alpha(SemanticColorToken.focus, 42000)) == 0) { + flex_free(&layout); return 0; + } + } + let fill: SemanticColorToken = SemanticColorToken.surface_interactive; + if (series.selected != 0) { fill = SemanticColorToken.surface_selected; } + if (series.disabled != 0) { fill = SemanticColorToken.surface_base; } + if (visual_phase != 0 && performance_chart_push_round(artifact, + bounds, radius, semantic_color_paint(fill)) == 0) { + flex_free(&layout); return 0; + } + let swatch: i64 = flex_spacing(FlexSpacingToken.medium, + FlexDensity.standard); + let swatch_alpha: i64 = 65535; + if (series.disabled != 0) { swatch_alpha = 22000; } + if (visual_phase != 0 && performance_chart_push_round(artifact, + rect(bounds.x + flex_spacing(FlexSpacingToken.small, + FlexDensity.standard), + bounds.y + (bounds.height - swatch) / 2, swatch, swatch), + swatch / 2, semantic_color_with_alpha(series.color_token, + swatch_alpha)) == 0) { + flex_free(&layout); return 0; + } + + if (visual_phase == 0) { + let hit: HitNode = hit_node(series.id, artifact.*.canvas.key, bounds); + hit.has_clip = 1; hit.clip = bounds; + hit.z_order = 20 + i as i64; + hit.enabled = (series.disabled == 0) as i32; + hit.focusable = (series.disabled == 0) as i32; + let hit_error: HitTreeError = hit_tree_add(&artifact.*.hits, hit); + if (hit_error != HitTreeError.none) { + flex_free(&layout); artifact.*.error = PerformanceChartError.hit_error; + artifact.*.hit_error = hit_error; return 0; + } + let semantic: SemanticsSpec = semantics_spec(series.id, + performance_chart_legend_id(spec.id), SemanticRole.button, series.name); + semantic.description = series.description; + semantic.value = "Available"; + if (series.disabled != 0) { semantic.value = "Unavailable"; } + else if (series.selected != 0) { semantic.value = "Selected"; } + semantic.disabled = series.disabled; + semantic.selected = series.selected; + semantic.has_bounds = 1; semantic.bounds = bounds; + if (series.disabled == 0) { + semantic.focus_order = 1 + i as i64; + semantic.action_mask = semantic_actions_add( + semantic_actions(SemanticAction.activate), SemanticAction.focus); + } + let series_semantic_error: SemanticsError = semantics_add( + &artifact.*.semantics, semantic); + if (series_semantic_error != SemanticsError.none) { + flex_free(&layout); artifact.*.error = PerformanceChartError.semantics_error; + artifact.*.semantics_error = series_semantic_error; return 0; + } + let text_left: i64 = bounds.x + + flex_spacing(FlexSpacingToken.small, FlexDensity.standard) + + swatch + flex_spacing(FlexSpacingToken.small, + FlexDensity.standard); + let text_bounds: Rect = rect(text_left, bounds.y, + bounds.x + bounds.width - text_left, bounds.height); + let text_color: SemanticColorToken = SemanticColorToken.text_primary; + if (series.disabled != 0) { + text_color = SemanticColorToken.text_disabled; + } + performance_chart_add_text_slot(artifact, series.id, + PerformanceChartTextSlotKind.legend_name, series.name, + text_bounds, SemanticTypeToken.label, text_color); + push[PerformanceChartLegendArtifact](&artifact.*.legend, + PerformanceChartLegendArtifact{ + .id = series.id, .bounds = bounds, + .selected = series.selected, .focused = series.focused, + .disabled = series.disabled, .color_token = series.color_token, + }); + } + i = i + 1; + } + flex_free(&layout); + return 1; +} + +fn performance_chart_table_cell(artifact: *PerformanceChartArtifact, + spec: PerformanceChartSpec, row_id: NodeKey, row: i64, column: i64, + name: []u8, value: []u8) i32 { + let cell: SemanticsSpec = semantics_spec( + performance_chart_table_cell_id(spec.id, row, column), + row_id, SemanticRole.cell, name); + cell.value = value; cell.row_index = row; cell.row_span = 1; + cell.column_index = column; cell.column_span = 1; + let error: SemanticsError = semantics_add(&artifact.*.semantics, cell); + if (error != SemanticsError.none) { + artifact.*.error = PerformanceChartError.semantics_error; + artifact.*.semantics_error = error; return 0; + } + return 1; +} + +fn performance_chart_table(artifact: *PerformanceChartArtifact, + spec: PerformanceChartSpec) i32 { + let rows: i64 = spec.series.data[0].points.len as i64 + 1; + let columns: i64 = spec.series.len as i64 + 1; + let table: SemanticsSpec = semantics_spec(performance_chart_table_id(spec.id), + artifact.*.canvas.key, SemanticRole.table, "Frame pacing data"); + table.description = "Data equivalent of the plotted series"; + table.row_count = rows; table.column_count = columns; + let table_error: SemanticsError = semantics_add(&artifact.*.semantics, table); + if (table_error != SemanticsError.none) { + artifact.*.error = PerformanceChartError.semantics_error; + artifact.*.semantics_error = table_error; return 0; + } + let row: i64 = 0; + while (row < rows) { + let row_id: NodeKey = performance_chart_table_row_id(spec.id, row); + let row_name: []u8 = "Performance sample"; + if (row == 0) { row_name = "Column headers"; } + let row_spec: SemanticsSpec = semantics_spec(row_id, + performance_chart_table_id(spec.id), SemanticRole.row, row_name); + row_spec.row_index = row; row_spec.row_span = 1; + let row_error: SemanticsError = semantics_add(&artifact.*.semantics, row_spec); + if (row_error != SemanticsError.none) { + artifact.*.error = PerformanceChartError.semantics_error; + artifact.*.semantics_error = row_error; return 0; + } + if (row == 0) { + if (performance_chart_table_cell(artifact, spec, row_id, row, 0, + spec.x_axis_name, spec.x_axis_unit) == 0) { return 0; } + let column: i32 = 0; + while (column < spec.series.len) { + if (performance_chart_table_cell(artifact, spec, row_id, row, + 1 + column as i64, spec.series.data[column].name, + spec.y_axis_unit) == 0) { return 0; } + column = column + 1; + } + } else { + let point_index: i32 = (row - 1) as i32; + let first: PerformanceChartPoint = + spec.series.data[0].points.data[point_index]; + if (performance_chart_table_cell(artifact, spec, row_id, row, 0, + spec.x_axis_name, first.x_value) == 0) { return 0; } + let column: i32 = 0; + while (column < spec.series.len) { + let point: PerformanceChartPoint = + spec.series.data[column].points.data[point_index]; + if (performance_chart_table_cell(artifact, spec, row_id, row, + 1 + column as i64, spec.series.data[column].name, + point.y_value) == 0) { return 0; } + column = column + 1; + } + } + row = row + 1; + } + artifact.*.table_rows = rows; artifact.*.table_columns = columns; + return 1; +} + +fn performance_chart_hash(artifact: PerformanceChartArtifact) i64 { + let hash: i64 = canvas_mix(17, artifact.canvas.content_hash); + hash = canvas_mix(hash, artifact.canvas.display.content_hash); + hash = canvas_mix(hash, artifact.hits.revision); + hash = canvas_mix(hash, artifact.semantics.revision); + hash = canvas_mix(hash, artifact.table_rows); + hash = canvas_mix(hash, artifact.table_columns); + let i: i32 = 0; + while (i < artifact.semantics.nodes.len) { + hash = canvas_mix(hash, + semantic_node_evidence_hash(artifact.semantics.nodes.data[i])); + i = i + 1; + } + i = 0; + while (i < artifact.text_slots.len) { + let slot: PerformanceChartTextSlot = artifact.text_slots.data[i]; + hash = canvas_mix(hash, slot.id.value); + hash = canvas_mix(hash, slot.id.generation); + hash = canvas_mix(hash, slot.kind as i64); + hash = canvas_text_hash(hash, slot.text); + hash = canvas_mix(hash, slot.bounds.x); + hash = canvas_mix(hash, slot.bounds.y); + hash = canvas_mix(hash, slot.bounds.width); + hash = canvas_mix(hash, slot.bounds.height); + hash = canvas_mix(hash, slot.type_token as i64); + hash = canvas_mix(hash, slot.color_token as i64); + i = i + 1; + } + return hash; +} + +fn performance_chart_build(spec: PerformanceChartSpec) PerformanceChartArtifact { + let artifact: PerformanceChartArtifact = performance_chart_empty(spec); + let validation: PerformanceChartError = performance_chart_spec_valid(spec); + if (validation != PerformanceChartError.none) { + artifact.error = validation; return artifact; + } + if (artifact.canvas.last_error != CanvasError.none) { + artifact.error = PerformanceChartError.canvas_error; + artifact.canvas_error = artifact.canvas.last_error; return artifact; + } + if (performance_chart_background(&artifact, spec) == 0 || + performance_chart_grid(&artifact, spec) == 0) { return artifact; } + let i: i32 = 0; + while (i < spec.series.len) { + if (performance_chart_series_marks(&artifact, + spec, spec.series.data[i], i) == 0) { return artifact; } + i = i + 1; + } + if (performance_chart_legend(&artifact, spec, 1) == 0) { return artifact; } + let sealed: CanvasError = canvas_seal(&artifact.canvas); + if (sealed != CanvasError.none) { + artifact.error = PerformanceChartError.canvas_error; + artifact.canvas_error = sealed; return artifact; + } + let canvas_semantics: CanvasError = canvas_contribute_semantics( + &artifact.canvas, &artifact.semantics); + if (canvas_semantics != CanvasError.none) { + artifact.error = PerformanceChartError.canvas_error; + artifact.canvas_error = canvas_semantics; return artifact; + } + let canvas_hit: CanvasError = canvas_contribute_hit( + &artifact.canvas, &artifact.hits); + if (canvas_hit != CanvasError.none) { + artifact.error = PerformanceChartError.canvas_error; + artifact.canvas_error = canvas_hit; return artifact; + } + if (performance_chart_add_static_semantics(&artifact, spec) == 0 || + performance_chart_legend(&artifact, spec, 0) == 0 || + performance_chart_table(&artifact, spec) == 0) { return artifact; } + artifact.evidence_hash = performance_chart_hash(artifact); + return artifact; +} diff --git a/tests/performance_chart_contract.zag b/tests/performance_chart_contract.zag new file mode 100644 index 0000000..eefa09f --- /dev/null +++ b/tests/performance_chart_contract.zag @@ -0,0 +1,382 @@ +@import("../src/components/performance_chart.zag") +@import("../src/automation/talkback.zag") +@import("../src/render/cpu_raster.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn chart_points(y0: i64, y1: i64, y2: i64, + y3: i64, t0: []u8, t1: []u8, t2: []u8, + t3: []u8) ArrayList[PerformanceChartPoint] { + let points: ArrayList[PerformanceChartPoint] = + make[PerformanceChartPoint](4); + push[PerformanceChartPoint](&points, + performance_chart_point(0, y0, "0 s", t0)); + push[PerformanceChartPoint](&points, + performance_chart_point(10, y1, "10 s", t1)); + push[PerformanceChartPoint](&points, + performance_chart_point(20, y2, "20 s", t2)); + push[PerformanceChartPoint](&points, + performance_chart_point(30, y3, "30 s", t3)); + return points; +} + +fn chart_spec(series: ArrayList[PerformanceChartSeries], + x_ticks: ArrayList[[]u8], y_ticks: ArrayList[[]u8]) PerformanceChartSpec { + let spec: PerformanceChartSpec = performance_chart_spec(node_key(7000), + rect(0, 0, 960 * unit_scale(), 560 * unit_scale()), + series, x_ticks, y_ticks); + spec.title = "Frame pacing performance"; + spec.description = "CPU and GPU frame times across a thirty-second run"; + spec.x_axis_name = "Elapsed time"; spec.x_axis_unit = "seconds"; + spec.y_axis_name = "Frame time"; spec.y_axis_unit = "milliseconds"; + spec.x_min = 0; spec.x_max = 30; + spec.y_min = 0; spec.y_max = 24; + spec.x_tick_count = 7; spec.y_tick_count = 7; + spec.baseline_value = 8; spec.baseline_name = "Eight millisecond baseline"; + spec.deadline_value = 16; spec.deadline_name = "Sixteen millisecond deadline"; + return spec; +} + +fn slot_count(artifact: *PerformanceChartArtifact, + kind: PerformanceChartTextSlotKind) i32 { + let count: i32 = 0; + let i: i32 = 0; + while (i < artifact.*.text_slots.len) { + if (artifact.*.text_slots.data[i].kind == kind) { count = count + 1; } + i = i + 1; + } + return count; +} + +fn slot_index(artifact: *PerformanceChartArtifact, id: NodeKey, + kind: PerformanceChartTextSlotKind) i32 { + let i: i32 = 0; + while (i < artifact.*.text_slots.len) { + if (node_key_equal(artifact.*.text_slots.data[i].id, id) != 0 && + artifact.*.text_slots.data[i].kind == kind) { return i; } + i = i + 1; + } + return 0 - 1; +} + +fn paint_rgb_equal(left: Paint, right: Paint) i32 { + return (left.red == right.red && left.green == right.green && + left.blue == right.blue) as i32; +} + +fn display_has_token(canvas: Canvas, token: SemanticColorToken) i32 { + let expected: Paint = semantic_color_paint(token); + let i: i32 = 0; + while (i < canvas.display.ops.len) { + if (paint_rgb_equal(canvas.display.ops.data[i].paint, expected) != 0) { + return 1; + } + i = i + 1; + } + return 0; +} + +fn display_kind_count(canvas: Canvas, kind: DisplayOpKind) i32 { + let count: i32 = 0; + let i: i32 = 0; + while (i < canvas.display.ops.len) { + if (canvas.display.ops.data[i].kind == kind) { count = count + 1; } + i = i + 1; + } + return count; +} + +fn component_contract(state: *TestState, + artifact: *PerformanceChartArtifact) void { + expect(state, artifact.*.error == PerformanceChartError.none && + artifact.*.canvas.sealed != 0 && + canvas_verify(&artifact.*.canvas) == CanvasError.none, + "bounded performance chart seals through first-party Canvas"); + expect(state, artifact.*.canvas.display.resources.resources.len == 3 && + display_kind_count(artifact.*.canvas, DisplayOpKind.draw_path) == 3 && + display_kind_count(artifact.*.canvas, + DisplayOpKind.fill_rounded_rect) >= 17, + "three line paths and twelve scatter marks are retained display content"); + expect(state, artifact.*.metrics.outer_padding == + flex_spacing(FlexSpacingToken.xlarge, FlexDensity.standard) && + artifact.*.metrics.legend_gap == + flex_spacing(FlexSpacingToken.small, FlexDensity.standard) && + artifact.*.legend.len == 3, + "chart rhythm and legend placement resolve named Flex spacing"); + let title_slot: i32 = slot_index(artifact, + performance_chart_title_id(artifact.*.canvas.key), + PerformanceChartTextSlotKind.title); + let deadline_slot: i32 = slot_index(artifact, + performance_chart_deadline_id(artifact.*.canvas.key), + PerformanceChartTextSlotKind.deadline); + expect(state, artifact.*.text_slots.len == 24 && title_slot >= 0 && + deadline_slot >= 0 && + slot_count(artifact, PerformanceChartTextSlotKind.x_axis_name) == 1 && + slot_count(artifact, PerformanceChartTextSlotKind.x_axis_unit) == 1 && + slot_count(artifact, PerformanceChartTextSlotKind.y_axis_name) == 1 && + slot_count(artifact, PerformanceChartTextSlotKind.y_axis_unit) == 1 && + slot_count(artifact, PerformanceChartTextSlotKind.x_tick) == 7 && + slot_count(artifact, PerformanceChartTextSlotKind.y_tick) == 7 && + slot_count(artifact, PerformanceChartTextSlotKind.legend_name) == 3 && + slot_count(artifact, PerformanceChartTextSlotKind.baseline) == 1 && + slot_count(artifact, PerformanceChartTextSlotKind.deadline) == 1, + "owned typography slots cover title axes units ticks legend and references"); + expect(state, + artifact.*.text_slots.data[title_slot].type_token == + SemanticTypeToken.title && + artifact.*.text_slots.data[title_slot].color_token == + SemanticColorToken.text_primary && + artifact.*.text_slots.data[deadline_slot].type_token == + SemanticTypeToken.caption && + artifact.*.text_slots.data[deadline_slot].color_token == + SemanticColorToken.status_error && + artifact.*.text_slots.data[title_slot].bounds.width > 0 && + artifact.*.text_slots.data[deadline_slot].bounds.height > 0, + "typography slots retain exact bounds semantic type and color provenance"); + expect(state, + display_has_token(artifact.*.canvas, SemanticColorToken.chart_grid) != 0 && + display_has_token(artifact.*.canvas, SemanticColorToken.chart_axis) != 0 && + display_has_token(artifact.*.canvas, SemanticColorToken.status_error) != 0 && + display_has_token(artifact.*.canvas, SemanticColorToken.focus) != 0, + "grid axis deadline and focus evidence use semantic color roles"); + + let x_index: i32 = semantics_find_index(artifact.*.semantics, + performance_chart_x_axis_id(artifact.*.canvas.key)); + let y_index: i32 = semantics_find_index(artifact.*.semantics, + performance_chart_y_axis_id(artifact.*.canvas.key)); + let baseline_index: i32 = semantics_find_index(artifact.*.semantics, + performance_chart_baseline_id(artifact.*.canvas.key)); + let deadline_index: i32 = semantics_find_index(artifact.*.semantics, + performance_chart_deadline_id(artifact.*.canvas.key)); + expect(state, x_index >= 0 && y_index >= 0 && + semantic_text_equal(artifact.*.semantics.nodes.data[x_index].name, + "Elapsed time") != 0 && + semantic_text_equal(artifact.*.semantics.nodes.data[x_index].value, + "seconds") != 0 && + semantic_text_equal(artifact.*.semantics.nodes.data[y_index].name, + "Frame time") != 0 && + semantic_text_equal(artifact.*.semantics.nodes.data[y_index].value, + "milliseconds") != 0, + "both axes expose names and units independently of pixels"); + expect(state, baseline_index >= 0 && deadline_index >= 0 && + semantic_text_equal( + artifact.*.semantics.nodes.data[baseline_index].name, + "Eight millisecond baseline") != 0 && + semantic_text_equal( + artifact.*.semantics.nodes.data[deadline_index].name, + "Sixteen millisecond deadline") != 0, + "baseline and deadline are named semantic references"); + + let selected_index: i32 = semantics_find_index( + artifact.*.semantics, node_key(7100)); + let available_index: i32 = semantics_find_index( + artifact.*.semantics, node_key(7101)); + let disabled_index: i32 = semantics_find_index( + artifact.*.semantics, node_key(7102)); + expect(state, selected_index >= 0 && available_index >= 0 && + disabled_index >= 0 && + artifact.*.semantics.nodes.data[selected_index].selected == 1 && + artifact.*.semantics.nodes.data[selected_index].focus_order == 1 && + semantic_actions_has( + artifact.*.semantics.nodes.data[selected_index].action_mask, + SemanticAction.focus) != 0 && + artifact.*.semantics.nodes.data[disabled_index].disabled == 1 && + artifact.*.semantics.nodes.data[disabled_index].action_mask == 0, + "legend exposes selected focusable available and disabled state truth"); + expect(state, artifact.*.hits.nodes.len == 4 && + artifact.*.hits.nodes.data[1].enabled == 1 && + artifact.*.hits.nodes.data[3].enabled == 0 && + artifact.*.legend.data[0].color_token == + SemanticColorToken.chart_series_primary && + artifact.*.legend.data[1].color_token == + SemanticColorToken.chart_series_secondary && + artifact.*.legend.data[2].color_token == + SemanticColorToken.chart_series_tertiary, + "legend hit targets and series colors share stable IDs and token mapping"); + + let table_index: i32 = semantics_find_index(artifact.*.semantics, + performance_chart_table_id(artifact.*.canvas.key)); + let cell_index: i32 = semantics_find_index(artifact.*.semantics, + performance_chart_table_cell_id(artifact.*.canvas.key, 2, 2)); + expect(state, table_index >= 0 && cell_index >= 0 && + artifact.*.semantics.nodes.data[table_index].role == SemanticRole.table && + artifact.*.semantics.nodes.data[table_index].row_count == 5 && + artifact.*.semantics.nodes.data[table_index].column_count == 4 && + artifact.*.semantics.nodes.data[cell_index].row_index == 2 && + artifact.*.semantics.nodes.data[cell_index].column_index == 2 && + semantic_text_equal(artifact.*.semantics.nodes.data[cell_index].name, + "GPU transport") != 0 && + semantic_text_equal(artifact.*.semantics.nodes.data[cell_index].value, + "7 ms") != 0, + "parallel data table preserves dimensions cell coordinates names and values"); +} + +fn talkback_contract(state: *TestState, + artifact: *PerformanceChartArtifact) void { + let session: TalkbackSession = talkback_session_make(talkback_capabilities()); + let viewport: Rect = artifact.*.canvas.bounds; + let selected: TalkbackResponse = talkback_dispatch(&session, + artifact.*.semantics, viewport, talkback_request(1, + TalkbackCommand.click, node_key(7100), + artifact.*.semantics.revision)); + let focused: TalkbackResponse = talkback_dispatch(&session, + artifact.*.semantics, viewport, talkback_request(2, + TalkbackCommand.focus, node_key(7100), + artifact.*.semantics.revision)); + let disabled: TalkbackResponse = talkback_dispatch(&session, + artifact.*.semantics, viewport, talkback_request(3, + TalkbackCommand.click, node_key(7102), + artifact.*.semantics.revision)); + let cell: TalkbackResponse = talkback_dispatch(&session, + artifact.*.semantics, viewport, talkback_request(4, + TalkbackCommand.query, + performance_chart_table_cell_id(artifact.*.canvas.key, 2, 2), + artifact.*.semantics.revision)); + expect(state, selected.status == TalkbackStatus.accepted && + focused.status == TalkbackStatus.accepted && + selected.target_id.value == 7100 && selected.emitted_action == 1, + "Talkback selects and focuses the named series by stable ID"); + expect(state, disabled.status == TalkbackStatus.action_unavailable, + "Talkback observes the disabled legend action contract"); + expect(state, cell.status == TalkbackStatus.accepted && + cell.resolved_role == SemanticRole.cell && + cell.row_index == 2 && cell.column_index == 2, + "Talkback queries the semantic data table without pixel inference"); + let pixel: TalkbackRequest = talkback_pixel_request(5, + TalkbackCommand.click, 100, 100, 1, 1); + let pixel_result: TalkbackResponse = talkback_dispatch(&session, + artifact.*.semantics, viewport, pixel); + expect(state, pixel_result.status == + TalkbackStatus.pixel_fallback_disabled, + "chart automation remains ID-first with pixel fallback disabled"); + talkback_session_free(&session); +} + +fn deterministic_contract(state: *TestState, spec: PerformanceChartSpec, + first: *PerformanceChartArtifact) void { + let second: PerformanceChartArtifact = performance_chart_build(spec); + expect(state, second.error == PerformanceChartError.none && + first.*.evidence_hash == 1042463816 && + second.evidence_hash == first.*.evidence_hash && + second.canvas.display.content_hash == + first.*.canvas.display.content_hash, + "identical chart input rebuilds identical retained evidence"); + let first_raster: CpuRasterResult = cpu_rasterize( + first.*.canvas.display, 960, 560); + let second_raster: CpuRasterResult = cpu_rasterize( + second.canvas.display, 960, 560); + let first_hash: i64 = 0; + let second_hash: i64 = 1; + if (first_raster.error == CpuRasterError.none) { + first_hash = cpu_surface_hash(first_raster.surface); + } + if (second_raster.error == CpuRasterError.none) { + second_hash = cpu_surface_hash(second_raster.surface); + } + expect(state, first_raster.error == CpuRasterError.none && + second_raster.error == CpuRasterError.none && + first_raster.pixels_touched > 0 && + first_hash == 1813314353 && first_hash == second_hash, + "CPU oracle renders the chart pixel-identically across two builds"); + _zag_print("performance chart evidence hash: "); + _zag_print_i64(first.*.evidence_hash); _zag_println(""); + _zag_print("performance chart CPU hash: "); + _zag_print_i64(first_hash); _zag_println(""); + cpu_raster_free(&second_raster); cpu_raster_free(&first_raster); + performance_chart_free(&second); +} + +fn failure_contract(state: *TestState, + source: ArrayList[PerformanceChartSeries], + base: PerformanceChartSpec) void { + let duplicate: ArrayList[PerformanceChartSeries] = + make[PerformanceChartSeries](2); + push[PerformanceChartSeries](&duplicate, source.data[0]); + let duplicate_series: PerformanceChartSeries = source.data[1]; + duplicate_series.id = source.data[0].id; + push[PerformanceChartSeries](&duplicate, duplicate_series); + let invalid_spec: PerformanceChartSpec = base; + invalid_spec.series = duplicate; + let invalid: PerformanceChartArtifact = performance_chart_build(invalid_spec); + expect(state, invalid.error == PerformanceChartError.invalid_series && + invalid.canvas.display.ops.len == 0 && invalid.semantics.nodes.len == 0, + "duplicate series IDs fail before display semantics or hit mutation"); + performance_chart_free(&invalid); free[PerformanceChartSeries](&duplicate); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + let cpu: ArrayList[PerformanceChartPoint] = chart_points( + 10, 11, 12, 10, "10 ms", "11 ms", "12 ms", "10 ms"); + let gpu: ArrayList[PerformanceChartPoint] = chart_points( + 8, 7, 9, 8, "8 ms", "7 ms", "9 ms", "8 ms"); + let fallback: ArrayList[PerformanceChartPoint] = chart_points( + 20, 19, 21, 20, "20 ms", "19 ms", "21 ms", "20 ms"); + let series: ArrayList[PerformanceChartSeries] = + make[PerformanceChartSeries](3); + let x_ticks: ArrayList[[]u8] = make[[]u8](7); + push[[]u8](&x_ticks, "0 s"); push[[]u8](&x_ticks, "5 s"); + push[[]u8](&x_ticks, "10 s"); push[[]u8](&x_ticks, "15 s"); + push[[]u8](&x_ticks, "20 s"); push[[]u8](&x_ticks, "25 s"); + push[[]u8](&x_ticks, "30 s"); + let y_ticks: ArrayList[[]u8] = make[[]u8](7); + push[[]u8](&y_ticks, "24 ms"); push[[]u8](&y_ticks, "20 ms"); + push[[]u8](&y_ticks, "16 ms"); push[[]u8](&y_ticks, "12 ms"); + push[[]u8](&y_ticks, "8 ms"); push[[]u8](&y_ticks, "4 ms"); + push[[]u8](&y_ticks, "0 ms"); + let cpu_series: PerformanceChartSeries = performance_chart_series( + node_key(7100), "CPU oracle", cpu, + SemanticColorToken.chart_series_primary); + cpu_series.description = "Deterministic reference renderer"; + cpu_series.selected = 1; cpu_series.focused = 1; + push[PerformanceChartSeries](&series, cpu_series); + let gpu_series: PerformanceChartSeries = performance_chart_series( + node_key(7101), "GPU transport", gpu, + SemanticColorToken.chart_series_secondary); + gpu_series.description = "Experimental accelerated transport"; + push[PerformanceChartSeries](&series, gpu_series); + let fallback_series: PerformanceChartSeries = performance_chart_series( + node_key(7102), "Unavailable fallback", fallback, + SemanticColorToken.chart_series_tertiary); + fallback_series.description = "Disabled backend comparison"; + fallback_series.disabled = 1; + push[PerformanceChartSeries](&series, fallback_series); + + let spec: PerformanceChartSpec = chart_spec(series, x_ticks, y_ticks); + let artifact: PerformanceChartArtifact = performance_chart_build(spec); + if (artifact.error != PerformanceChartError.none) { + _zag_print("chart build error canvas path semantics hit: "); + _zag_print_i64(artifact.error as i64); _zag_print(" "); + _zag_print_i64(artifact.canvas_error as i64); _zag_print(" "); + _zag_print_i64(artifact.path_error as i64); _zag_print(" "); + _zag_print_i64(artifact.semantics_error as i64); _zag_print(" "); + _zag_print_i64(artifact.hit_error as i64); _zag_println(""); + } + component_contract(&state, &artifact); + talkback_contract(&state, &artifact); + deterministic_contract(&state, spec, &artifact); + failure_contract(&state, series, spec); + performance_chart_free(&artifact); + free[PerformanceChartSeries](&series); + free[PerformanceChartPoint](&fallback); + free[PerformanceChartPoint](&gpu); + free[PerformanceChartPoint](&cpu); + free[[]u8](&y_ticks); free[[]u8](&x_ticks); + + _zag_print("Performance chart contract: pass="); + _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); + _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-headless.sh b/tools/test-headless.sh index 182116a..9774cd6 100755 --- a/tools/test-headless.sh +++ b/tools/test-headless.sh @@ -51,6 +51,8 @@ fi "$tmp/button-gallery-contract" "$gallery_font" "$znc" tests/canvas_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/canvas-contract" "$tmp/canvas-contract" +"$znc" tests/performance_chart_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/performance-chart-contract" +"$tmp/performance-chart-contract" "$znc" tests/measure_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/measure-contract" "$tmp/measure-contract" "$znc" tests/overlay_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/overlay-contract" @@ -109,4 +111,4 @@ printf 'Linux preview CPU snapshot contract: pass=2 fail=0\n' "$znc" tests/motion_contract.zag --no-zagd --analyze-strict --no-foreground-cache -o "$tmp/motion-contract" "$tmp/motion-contract" -printf 'headless test: PASS (backend truth, state, reconciliation, intrinsic measurement, constraints, Flex, shared Button state/render/hit/semantics/Talkback plus its executable conformance gallery, retained Canvas immediate drawing, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, Unicode grapheme segmentation, positioned glyph-run transport, collection semantics, Talkback, native-preview semantics, owned render resources, canonical paths and images, bounded PNG decode, display lists, analytic rounded geometry, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion)\n' +printf 'headless test: PASS (backend truth, state, reconciliation, intrinsic measurement, constraints, Flex, shared Button state/render/hit/semantics/Talkback plus its executable conformance gallery, retained Canvas immediate drawing, bounded accessible performance-chart anatomy, Grid, Overlay, scroll, virtual list, Table, Tree, recycling, Unicode grapheme segmentation, positioned glyph-run transport, collection semantics, Talkback, native-preview semantics, owned render resources, canonical paths and images, bounded PNG decode, display lists, analytic rounded geometry, CPU shape and image raster, deterministic PNG snapshots, input, replay, and motion)\n' diff --git a/tools/test-performance-chart.sh b/tools/test-performance-chart.sh new file mode 100755 index 0000000..26adbb2 --- /dev/null +++ b/tools/test-performance-chart.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +znc=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} + +if [ ! -x "$znc" ]; then + printf 'performance chart contract: FAIL: Zag compiler not executable: %s\n' "$znc" >&2 + exit 2 +fi + +binary=$(mktemp /tmp/zagkit-test-performance-chart.XXXXXX) +cleanup() { rm -f -- "$binary"; } +trap cleanup EXIT + +"$znc" "$root/tests/performance_chart_contract.zag" --no-zagd \ + --analyze-strict --no-foreground-cache -o "$binary" +"$binary" From 035584355fd26c5ba0ed957ea424a7591a65120b Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 06:55:39 -0700 Subject: [PATCH 090/103] Add adaptive Linux showcase placement --- src/showcase/linux_preview_layout.zag | 324 ++++++++++++++++++++++++ tests/linux_preview_layout_contract.zag | 145 +++++++++++ tools/test-linux-preview-layout.sh | 11 + 3 files changed, 480 insertions(+) create mode 100644 src/showcase/linux_preview_layout.zag create mode 100644 tests/linux_preview_layout_contract.zag create mode 100755 tools/test-linux-preview-layout.sh diff --git a/src/showcase/linux_preview_layout.zag b/src/showcase/linux_preview_layout.zag new file mode 100644 index 0000000..39d3329 --- /dev/null +++ b/src/showcase/linux_preview_layout.zag @@ -0,0 +1,324 @@ +@import("../layout/adaptive.zag") + +// Shared placement authority for the native showcase. Rendering, semantics, +// hit testing, and Talkback must consume these same rectangles; no layer gets +// to maintain a parallel set of scaled magic coordinates. + +enum LinuxPreviewLayoutMode { expanded, medium, compact } + +enum LinuxPreviewLayoutError { + none, + invalid_viewport, + flex_error, + chart_contract_violation, +} + +struct LinuxPreviewLayout { + error: LinuxPreviewLayoutError, + flex_error: FlexError, + mode: LinuxPreviewLayoutMode, + viewport: Rect, + header: Rect, + header_status: Rect, + navigation: Rect, + main: Rect, + status: Rect, + page_heading: Rect, + chart: Rect, + segmented_control: Rect, + compact_summary: Rect, + navigation_is_column: i32, + status_visible: i32, + chart_visible: i32, + segmented_control_visible: i32, + outer_spacing: i64, + panel_padding: i64, + content_gap: i64, + section_gap: i64, +} + +fn linux_preview_layout_empty(error: LinuxPreviewLayoutError) + LinuxPreviewLayout { + return LinuxPreviewLayout{ + .error = error, + .flex_error = FlexError.none, + .mode = LinuxPreviewLayoutMode.compact, + .viewport = rect(0, 0, 0, 0), + .header = rect(0, 0, 0, 0), + .header_status = rect(0, 0, 0, 0), + .navigation = rect(0, 0, 0, 0), + .main = rect(0, 0, 0, 0), + .status = rect(0, 0, 0, 0), + .page_heading = rect(0, 0, 0, 0), + .chart = rect(0, 0, 0, 0), + .segmented_control = rect(0, 0, 0, 0), + .compact_summary = rect(0, 0, 0, 0), + .navigation_is_column = 0, + .status_visible = 0, + .chart_visible = 0, + .segmented_control_visible = 0, + .outer_spacing = 0, + .panel_padding = 0, + .content_gap = 0, + .section_gap = 0, + }; +} + +fn linux_preview_layout_mode(width: i32, height: i32) + LinuxPreviewLayoutMode { + if (width >= 1280 && height >= 800) { + return LinuxPreviewLayoutMode.expanded; + } + if (width >= 960 && height >= 720) { + return LinuxPreviewLayoutMode.medium; + } + return LinuxPreviewLayoutMode.compact; +} + +fn linux_preview_layout_offset(value: Rect, x: i64, y: i64) Rect { + return rect(value.x + x, value.y + y, value.width, value.height); +} + +fn linux_preview_layout_column_frame(layout: @borrows *FlexResult, + id: i64) Rect { + let index: i32 = 0; + while (index < layout.*.placements.len) { + if (layout.*.placements.data[index].id == id) { + return layout.*.placements.data[index].frame; + } + index = index + 1; + } + return rect(0, 0, 0, 0); +} + +fn linux_preview_layout(width: i32, height: i32) LinuxPreviewLayout { + // The desktop showcase intentionally fails closed below the smallest + // viewport that can preserve its header, 48 px route strip, and a useful + // compact summary. Mobile shells have a separate placement contract. + if (width < 480 || height < 360 || width > 4096 || height > 2160) { + return linux_preview_layout_empty( + LinuxPreviewLayoutError.invalid_viewport); + } + + let result: LinuxPreviewLayout = linux_preview_layout_empty( + LinuxPreviewLayoutError.none); + result.mode = linux_preview_layout_mode(width, height); + result.viewport = rect(0, 0, (width as i64) * unit_scale(), + (height as i64) * unit_scale()); + + let density: FlexDensity = FlexDensity.standard; + let outer_token: FlexSpacingToken = FlexSpacingToken.xlarge; + let panel_token: FlexSpacingToken = FlexSpacingToken.xlarge; + if (result.mode == LinuxPreviewLayoutMode.compact) { + outer_token = FlexSpacingToken.large; + panel_token = FlexSpacingToken.large; + } + result.outer_spacing = flex_spacing(outer_token, density); + result.panel_padding = flex_spacing(panel_token, density); + result.content_gap = flex_spacing(FlexSpacingToken.large, density); + result.section_gap = flex_spacing(FlexSpacingToken.small, density); + + let header_height: i64 = + flex_spacing(FlexSpacingToken.xxlarge, density) * 2; + result.header = rect(result.outer_spacing, result.outer_spacing, + result.viewport.width - result.outer_spacing * 2, header_height); + let status_width: i64 = 200 * unit_scale(); + let status_height: i64 = 40 * unit_scale(); + result.header_status = rect( + result.header.x + result.header.width - result.panel_padding - + status_width, + result.header.y + (result.header.height - status_height) / 2, + status_width, status_height); + + let body_y: i64 = result.header.y + result.header.height + + result.content_gap; + let body_height: i64 = result.viewport.height - result.outer_spacing - + body_y; + let body_width: i64 = result.viewport.width - result.outer_spacing * 2; + + if (result.mode != LinuxPreviewLayoutMode.compact) { + let columns: ArrayList[FlexItem] = make[FlexItem](3); + let nav_width: i64 = 192 * unit_scale(); + if (result.mode == LinuxPreviewLayoutMode.expanded) { + nav_width = 216 * unit_scale(); + } + let nav: FlexItem = flex_item(1, nav_width, body_height); + nav.min_main = nav_width; nav.max_main = nav_width; + nav.shrink = 0; + push[FlexItem](&columns, nav); + + let main_minimum: i64 = 688 * unit_scale(); + let main_item: FlexItem = flex_item(2, main_minimum, body_height); + main_item.min_main = main_minimum; + main_item.grow = 1; main_item.shrink = 0; + push[FlexItem](&columns, main_item); + + if (result.mode == LinuxPreviewLayoutMode.expanded) { + let rail_width: i64 = 264 * unit_scale(); + let status_item: FlexItem = flex_item(3, rail_width, body_height); + status_item.min_main = rail_width; + status_item.max_main = rail_width; + status_item.shrink = 0; + push[FlexItem](&columns, status_item); + result.status_visible = 1; + } + + let style: FlexStyle = flex_style(FlexAxis.row, + result.content_gap, insets_all(0)); + style.cross_alignment = CrossAlignment.stretch; + let placed: FlexResult = flex_layout(columns, + tight_constraints(body_width, body_height), style); + free[FlexItem](&columns); + if (placed.error != FlexError.none || placed.overflow != 0 || + placed.duplicate_ids != 0) { + result.error = LinuxPreviewLayoutError.flex_error; + result.flex_error = placed.error; + flex_free(&placed); + return result; + } + result.navigation = linux_preview_layout_offset( + linux_preview_layout_column_frame(&placed, 1), + result.outer_spacing, body_y); + result.main = linux_preview_layout_offset( + linux_preview_layout_column_frame(&placed, 2), + result.outer_spacing, body_y); + if (result.status_visible != 0) { + result.status = linux_preview_layout_offset( + linux_preview_layout_column_frame(&placed, 3), + result.outer_spacing, body_y); + } + result.navigation_is_column = 1; + flex_free(&placed); + } else { + let navigation_height: i64 = + flex_spacing(FlexSpacingToken.xxlarge, density) + + flex_spacing(FlexSpacingToken.large, density); + result.navigation = rect(result.outer_spacing, body_y, body_width, + navigation_height); + let main_y: i64 = result.navigation.y + result.navigation.height + + result.content_gap; + result.main = rect(result.outer_spacing, main_y, body_width, + result.viewport.height - result.outer_spacing - main_y); + } + + let heading_height: i64 = + flex_spacing(FlexSpacingToken.xxlarge, density) + + flex_spacing(FlexSpacingToken.large, density); + let control_height: i64 = + flex_spacing(FlexSpacingToken.xxlarge, density) + + flex_spacing(FlexSpacingToken.medium, density); + let inner_width: i64 = result.main.width - result.panel_padding * 2; + let inner_height: i64 = result.main.height - result.panel_padding * 2; + result.page_heading = rect(result.main.x + result.panel_padding, + result.main.y + result.panel_padding, inner_width, heading_height); + let chart_y: i64 = result.page_heading.y + result.page_heading.height + + result.section_gap; + let chart_height: i64 = inner_height - heading_height - + result.section_gap * 2 - control_height; + + if (inner_width >= 640 * unit_scale() && + chart_height >= 420 * unit_scale()) { + result.chart_visible = 1; + result.segmented_control_visible = 1; + result.chart = rect(result.main.x + result.panel_padding, chart_y, + inner_width, chart_height); + let segmented_width: i64 = 520 * unit_scale(); + if (segmented_width > inner_width) { segmented_width = inner_width; } + result.segmented_control = rect(result.chart.x, + result.chart.y + result.chart.height + result.section_gap, + segmented_width, control_height); + } else { + result.compact_summary = rect(result.main.x + result.panel_padding, + chart_y, inner_width, + result.main.y + result.main.height - result.panel_padding - + chart_y); + } + + if (result.mode != LinuxPreviewLayoutMode.compact && + result.chart_visible == 0) { + result.error = LinuxPreviewLayoutError.chart_contract_violation; + } + return result; +} + +fn linux_preview_navigation_item_bounds(layout: LinuxPreviewLayout, + index: i32, item_count: i32) Rect { + if (layout.error != LinuxPreviewLayoutError.none || index < 0 || + item_count <= 0 || index >= item_count) { + return rect(0, 0, 0, 0); + } + let density: FlexDensity = FlexDensity.standard; + let inset: i64 = flex_spacing(FlexSpacingToken.large, density); + let gap: i64 = flex_spacing(FlexSpacingToken.small, density); + let target: i64 = 48 * unit_scale(); + if (layout.navigation_is_column != 0) { + return rect(layout.navigation.x + inset, + layout.navigation.y + inset + (target + gap) * (index as i64), + layout.navigation.width - inset * 2, target); + } + let available: i64 = layout.navigation.width - inset * 2 - + gap * ((item_count - 1) as i64); + let item_width: i64 = available / (item_count as i64); + let leading: i64 = inset + (available - + item_width * (item_count as i64)) / 2; + return rect(layout.navigation.x + leading + + (item_width + gap) * (index as i64), + layout.navigation.y + (layout.navigation.height - target) / 2, + item_width, target); +} + +fn linux_preview_inspect_bounds(layout: LinuxPreviewLayout) Rect { + if (layout.error != LinuxPreviewLayoutError.none || + layout.page_heading.width <= 0) { + return rect(0, 0, 0, 0); + } + let width: i64 = 120 * unit_scale(); + let height: i64 = 44 * unit_scale(); + return rect(layout.page_heading.x + layout.page_heading.width - width, + layout.page_heading.y + (layout.page_heading.height - height) / 2, + width, height); +} + +fn linux_preview_segment_bounds(layout: LinuxPreviewLayout, + index: i32, item_count: i32) Rect { + if (layout.error != LinuxPreviewLayoutError.none || + layout.segmented_control_visible == 0 || index < 0 || + item_count <= 0 || index >= item_count) { + return rect(0, 0, 0, 0); + } + let item_width: i64 = layout.segmented_control.width / + (item_count as i64); + let right: i64 = layout.segmented_control.x + + layout.segmented_control.width; + let x: i64 = layout.segmented_control.x + + item_width * (index as i64); + let width: i64 = item_width; + if (index == item_count - 1) { width = right - x; } + return rect(x, layout.segmented_control.y, width, + layout.segmented_control.height); +} + +fn linux_preview_status_item_bounds(layout: LinuxPreviewLayout, + index: i32, item_count: i32) Rect { + if (layout.error != LinuxPreviewLayoutError.none || + layout.status_visible == 0 || index < 0 || item_count <= 0 || + index >= item_count) { + return rect(0, 0, 0, 0); + } + let density: FlexDensity = FlexDensity.standard; + let inset: i64 = flex_spacing(FlexSpacingToken.large, density); + let gap: i64 = flex_spacing(FlexSpacingToken.small, density); + let desired_height: i64 = 96 * unit_scale(); + let available: i64 = layout.status.height - inset * 2 - + gap * ((item_count - 1) as i64); + let item_height: i64 = desired_height; + if (item_height * (item_count as i64) > available) { + item_height = available / (item_count as i64); + } + if (item_height < 44 * unit_scale()) { + return rect(0, 0, 0, 0); + } + return rect(layout.status.x + inset, + layout.status.y + inset + (item_height + gap) * (index as i64), + layout.status.width - inset * 2, item_height); +} diff --git a/tests/linux_preview_layout_contract.zag b/tests/linux_preview_layout_contract.zag new file mode 100644 index 0000000..16b3458 --- /dev/null +++ b/tests/linux_preview_layout_contract.zag @@ -0,0 +1,145 @@ +@import("../src/showcase/linux_preview_layout.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn inside(outer: Rect, inner: Rect) i32 { + return (inner.width > 0 && inner.height > 0 && + inner.x >= outer.x && inner.y >= outer.y && + inner.x + inner.width <= outer.x + outer.width && + inner.y + inner.height <= outer.y + outer.height) as i32; +} + +fn target_is_accessible(bounds: Rect) i32 { + return (bounds.width >= 44 * unit_scale() && + bounds.height >= 44 * unit_scale()) as i32; +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + let expanded: LinuxPreviewLayout = linux_preview_layout(1360, 900); + expect(&state, expanded.error == LinuxPreviewLayoutError.none && + expanded.mode == LinuxPreviewLayoutMode.expanded && + expanded.status_visible == 1 && expanded.navigation_is_column == 1, + "expanded placement has one navigation column and a status-only rail"); + expect(&state, expanded.chart_visible == 1 && + expanded.segmented_control_visible == 1 && + expanded.chart.width >= 640 * unit_scale() && + expanded.chart.height >= 420 * unit_scale(), + "expanded placement honors the chart component minimum"); + expect(&state, inside(expanded.viewport, expanded.header) != 0 && + inside(expanded.viewport, expanded.navigation) != 0 && + inside(expanded.viewport, expanded.main) != 0 && + inside(expanded.viewport, expanded.status) != 0 && + inside(expanded.main, expanded.chart) != 0, + "expanded regions remain inside their declared containers"); + expect(&state, expanded.navigation.x + expanded.navigation.width + + expanded.content_gap == expanded.main.x && + expanded.main.x + expanded.main.width + expanded.content_gap == + expanded.status.x, + "expanded columns use the public Flex spacing gap exactly"); + expect(&state, rect_intersects(expanded.navigation, expanded.main) == 0 && + rect_intersects(expanded.main, expanded.status) == 0 && + rect_intersects(expanded.header, expanded.main) == 0, + "expanded macro regions do not overlap"); + + let medium: LinuxPreviewLayout = linux_preview_layout(1120, 720); + expect(&state, medium.error == LinuxPreviewLayoutError.none && + medium.mode == LinuxPreviewLayoutMode.medium && + medium.status_visible == 0 && medium.status.width == 0 && + medium.navigation_is_column == 1, + "medium placement removes the competing rail without hiding navigation"); + expect(&state, medium.chart_visible == 1 && + medium.chart.width >= 640 * unit_scale() && + medium.chart.height >= 420 * unit_scale(), + "the 1120 by 720 native default fits the real chart contract"); + expect(&state, medium.main.x + medium.main.width == + medium.viewport.width - medium.outer_spacing, + "medium main content consumes the released status-rail width"); + let medium_first_nav: Rect = linux_preview_navigation_item_bounds( + medium, 0, 6); + let medium_last_nav: Rect = linux_preview_navigation_item_bounds( + medium, 5, 6); + let medium_inspect: Rect = linux_preview_inspect_bounds(medium); + let medium_last_segment: Rect = linux_preview_segment_bounds( + medium, 3, 4); + expect(&state, inside(medium.navigation, medium_first_nav) != 0 && + inside(medium.navigation, medium_last_nav) != 0 && + target_is_accessible(medium_first_nav) != 0 && + target_is_accessible(medium_last_nav) != 0, + "medium navigation targets share accessible placement bounds"); + expect(&state, inside(medium.page_heading, medium_inspect) != 0 && + target_is_accessible(medium_inspect) != 0 && + inside(medium.segmented_control, medium_last_segment) != 0 && + target_is_accessible(medium_last_segment) != 0, + "Inspect and every segment have at least a 44 pixel target"); + + let tight: LinuxPreviewLayout = linux_preview_layout(960, 720); + expect(&state, tight.error == LinuxPreviewLayoutError.none && + tight.mode == LinuxPreviewLayoutMode.medium && + tight.chart.width == 656 * unit_scale() && + tight.chart.height == 436 * unit_scale(), + "tight medium placement preserves the chart contract without clipping"); + + let compact: LinuxPreviewLayout = linux_preview_layout(800, 600); + expect(&state, compact.error == LinuxPreviewLayoutError.none && + compact.mode == LinuxPreviewLayoutMode.compact && + compact.navigation_is_column == 0 && compact.status_visible == 0, + "compact placement changes navigation structure instead of scaling it down"); + expect(&state, compact.chart_visible == 0 && + compact.segmented_control_visible == 0 && + inside(compact.main, compact.compact_summary) != 0, + "compact placement uses an honest summary when the chart cannot fit"); + let compact_first_nav: Rect = linux_preview_navigation_item_bounds( + compact, 0, 6); + let compact_last_nav: Rect = linux_preview_navigation_item_bounds( + compact, 5, 6); + expect(&state, inside(compact.navigation, compact_first_nav) != 0 && + inside(compact.navigation, compact_last_nav) != 0 && + target_is_accessible(compact_first_nav) != 0 && + target_is_accessible(compact_last_nav) != 0 && + rect_intersects(compact_first_nav, compact_last_nav) == 0, + "compact route strip preserves six distinct accessible targets"); + expect(&state, compact.outer_spacing == flex_spacing( + FlexSpacingToken.large, FlexDensity.standard) && + medium.outer_spacing == flex_spacing( + FlexSpacingToken.xlarge, FlexDensity.standard) && + medium.section_gap == flex_spacing( + FlexSpacingToken.small, FlexDensity.standard), + "responsive placement derives outer rhythm from named Flex tokens"); + + let threshold: LinuxPreviewLayout = linux_preview_layout(1280, 800); + expect(&state, threshold.error == LinuxPreviewLayoutError.none && + threshold.mode == LinuxPreviewLayoutMode.expanded && + threshold.chart_visible == 1, + "expanded threshold is component-capacity based and executable"); + let minimum: LinuxPreviewLayout = linux_preview_layout(480, 360); + expect(&state, minimum.error == LinuxPreviewLayoutError.none && + minimum.mode == LinuxPreviewLayoutMode.compact && + inside(minimum.main, minimum.compact_summary) != 0, + "minimum desktop viewport retains a useful compact summary"); + let invalid: LinuxPreviewLayout = linux_preview_layout(479, 360); + expect(&state, invalid.error == LinuxPreviewLayoutError.invalid_viewport, + "invalid native viewports fail closed"); + let legacy_tiny: LinuxPreviewLayout = linux_preview_layout(320, 240); + expect(&state, legacy_tiny.error == + LinuxPreviewLayoutError.invalid_viewport && + legacy_tiny.viewport.width == 0 && legacy_tiny.main.width == 0, + "legacy 320 by 240 desktop geometry cannot masquerade as adaptive UI"); + + _zag_print("Linux preview layout: pass="); + _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); + _zag_println(""); + if (state.failed != 0) { return 1; } + return 0; +} diff --git a/tools/test-linux-preview-layout.sh b/tools/test-linux-preview-layout.sh new file mode 100755 index 0000000..2470b5f --- /dev/null +++ b/tools/test-linux-preview-layout.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +znc=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} +output=${TMPDIR:-/tmp}/zagkit-linux-preview-layout-contract + +cd "$root" +"$znc" tests/linux_preview_layout_contract.zag --no-zagd \ + --analyze-strict --no-foreground-cache -o "$output" +"$output" From cb5fc8cbfa09931c2df4e2bef3da79557f168a15 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 06:57:22 -0700 Subject: [PATCH 091/103] Share adaptive preview semantics geometry --- src/showcase/linux_preview_model.zag | 54 ++++ src/showcase/linux_preview_semantics.zag | 275 ++++++++++++--------- tests/linux_preview_semantics_contract.zag | 89 +++++-- 3 files changed, 285 insertions(+), 133 deletions(-) create mode 100644 src/showcase/linux_preview_model.zag diff --git a/src/showcase/linux_preview_model.zag b/src/showcase/linux_preview_model.zag new file mode 100644 index 0000000..3a70a23 --- /dev/null +++ b/src/showcase/linux_preview_model.zag @@ -0,0 +1,54 @@ +// One source of truth for the experimental Linux showcase routes. A route is +// enabled only after its visible content, semantics, actions, and Talkback +// identity are composed into the native surface. + +fn linux_preview_navigation_count() i32 { return 6; } + +fn linux_preview_initial_navigation() i32 { return 0; } + +fn linux_preview_navigation_name(index: i32) []u8 { + if (index == 1) { return "Components"; } + if (index == 2) { return "Motion"; } + if (index == 3) { return "Typography"; } + if (index == 4) { return "Talkback"; } + if (index == 5) { return "Settings"; } + return "Overview"; +} + +fn linux_preview_navigation_enabled(index: i32) i32 { + // The overview is currently the only route fully composed into the native + // shell. Components becomes available when the real Button gallery is + // embedded; a highlight-only destination is deliberately unavailable. + return (index == 0) as i32; +} + +fn linux_preview_navigation_value(index: i32) []u8 { + if (linux_preview_navigation_enabled(index) != 0) { return "Available"; } + return "Planned; native composition unavailable"; +} + +fn linux_preview_navigation_normalize(index: i32) i32 { + if (index >= 0 && index < linux_preview_navigation_count() && + linux_preview_navigation_enabled(index) != 0) { return index; } + return linux_preview_initial_navigation(); +} + +fn linux_preview_main_heading(index: i32) []u8 { + let selected: i32 = linux_preview_navigation_normalize(index); + if (selected == 1) { return "Button states"; } + if (selected == 2) { return "Motion continuity"; } + if (selected == 3) { return "Typography system"; } + if (selected == 4) { return "Talkback inspector"; } + if (selected == 5) { return "Showcase settings"; } + return "Renderer performance"; +} + +fn linux_preview_main_description(index: i32) []u8 { + let selected: i32 = linux_preview_navigation_normalize(index); + if (selected == 1) { return "Canonical Button variants and interaction states"; } + if (selected == 2) { return "Interruptible motion and reduced-motion evidence"; } + if (selected == 3) { return "Type roles, shaping, fallback, and large-text evidence"; } + if (selected == 4) { return "Stable-ID semantics and agent automation evidence"; } + if (selected == 5) { return "Theme, contrast, density, and effects controls"; } + return "Deterministic CPU frame-time chart and pipeline controls"; +} diff --git a/src/showcase/linux_preview_semantics.zag b/src/showcase/linux_preview_semantics.zag index 184cd90..2f9e762 100644 --- a/src/showcase/linux_preview_semantics.zag +++ b/src/showcase/linux_preview_semantics.zag @@ -1,25 +1,29 @@ @import("../semantics/semantics.zag") @import("../design/tokens.zag") +@import("linux_preview_model.zag") +@import("linux_preview_layout.zag") -fn preview_semantics_x(value: i64, width: i32) i64 { - return (value * (width as i64) * unit_scale()) / 1120; -} - -fn preview_semantics_y(value: i64, height: i32) i64 { - return (value * (height as i64) * unit_scale()) / 720; +fn linux_preview_navigation_hit_at_pixel(x: i32, y: i32, + width: i32, height: i32) i32 { + let layout: LinuxPreviewLayout = linux_preview_layout(width, height); + if (layout.error != LinuxPreviewLayoutError.none) { return 0 - 1; } + let fixed_x: i64 = (x as i64) * unit_scale(); + let fixed_y: i64 = (y as i64) * unit_scale(); + let index: i32 = 0; + while (index < linux_preview_navigation_count()) { + let bounds: Rect = linux_preview_navigation_item_bounds(layout, index, + linux_preview_navigation_count()); + if (rect_contains(bounds, fixed_x, fixed_y) != 0) { return index; } + index = index + 1; + } + return 0 - 1; } fn linux_preview_navigation_at_pixel(x: i32, y: i32, width: i32, height: i32) i32 { - if (width <= 0 || height <= 0) { return 0 - 1; } - let logical_x: i64 = (x as i64) * 1120 / width; - let logical_y: i64 = (y as i64) * 720 / height; - if (logical_x < 42 || logical_x >= 218) { return 0 - 1; } - let index: i32 = 0; - while (index < 6) { - let top: i64 = 129 + (index as i64) * 70; - if (logical_y >= top && logical_y < top + 48) { return index; } - index = index + 1; + let index: i32 = linux_preview_navigation_hit_at_pixel(x, y, width, height); + if (index >= 0 && linux_preview_navigation_enabled(index) != 0) { + return index; } return 0 - 1; } @@ -34,7 +38,24 @@ fn linux_preview_activity_id() NodeKey { return node_key(20500); } fn linux_preview_activity_item_id(index: i32) NodeKey { return node_key(20600 + index); } fn linux_preview_chart_table_id() NodeKey { return node_key(20700); } fn linux_preview_chart_cell_id(index: i32) NodeKey { return node_key(20710 + index); } -fn linux_preview_semantic_node_count() i32 { return 38; } +fn linux_preview_compact_summary_id() NodeKey { return node_key(20740); } + +fn linux_preview_semantic_node_count(width: i32, height: i32) i32 { + let layout: LinuxPreviewLayout = linux_preview_layout(width, height); + if (layout.error != LinuxPreviewLayoutError.none) { return 2; } + // Window, navigation group + six tabs, main group, heading, and Inspect. + let count: i32 = 11; + if (layout.chart_visible != 0) { + // Four stage tabs, chart table, and sixteen table cells. + count = count + 21; + } else { + count = count + 1; + } + if (layout.status_visible != 0) { + count = count + 6; + } + return count; +} fn linux_preview_chart_sample_name(index: i32) []u8 { if (index == 1) { return "Sample 2"; } @@ -64,6 +85,9 @@ fn linux_preview_add_semantic(tree: *SemanticsTree, spec: SemanticsSpec) i32 { fn linux_preview_semantics(width: i32, height: i32, selected_navigation: i32) SemanticsTree { + let selected_route: i32 = linux_preview_navigation_normalize( + selected_navigation); + let layout: LinuxPreviewLayout = linux_preview_layout(width, height); let tree: SemanticsTree = semantics_tree_make(); let root: SemanticsSpec = semantics_spec(linux_preview_root_id(), semantic_root_key(), SemanticRole.window, "Zagkit Studio Linux Preview"); @@ -73,136 +97,153 @@ fn linux_preview_semantics(width: i32, height: i32, (height as i64) * unit_scale()); _ = linux_preview_add_semantic(&tree, root); + if (layout.error != LinuxPreviewLayoutError.none) { + let unsupported: SemanticsSpec = semantics_spec( + linux_preview_compact_summary_id(), linux_preview_root_id(), + SemanticRole.text, "Unsupported preview size"); + unsupported.description = + "The Linux desktop preview needs at least 480 by 360 logical pixels"; + unsupported.has_bounds = 1; + unsupported.bounds = root.bounds; + _ = linux_preview_add_semantic(&tree, unsupported); + return tree; + } + let navigation: SemanticsSpec = semantics_spec(linux_preview_navigation_id(), linux_preview_root_id(), SemanticRole.group, "Primary navigation"); navigation.has_bounds = 1; - navigation.bounds = rect(preview_semantics_x(24, width), - preview_semantics_y(104, height), preview_semantics_x(212, width), - preview_semantics_y(592, height)); + navigation.bounds = layout.navigation; _ = linux_preview_add_semantic(&tree, navigation); let nav_index: i32 = 0; - while (nav_index < 6) { - let name: []u8 = "Overview"; - if (nav_index == 1) { name = "Components"; } - else if (nav_index == 2) { name = "Motion"; } - else if (nav_index == 3) { name = "Typography"; } - else if (nav_index == 4) { name = "Talkback"; } - else if (nav_index == 5) { name = "Settings"; } + while (nav_index < linux_preview_navigation_count()) { + let name: []u8 = linux_preview_navigation_name(nav_index); let tab: SemanticsSpec = semantics_spec(linux_preview_nav_id(nav_index), linux_preview_navigation_id(), SemanticRole.tab, name); - tab.action_mask = semantic_actions_add( - semantic_actions_add(semantic_actions(SemanticAction.activate), - SemanticAction.select), SemanticAction.focus); - tab.focus_order = nav_index + 1; - tab.selected = (nav_index == selected_navigation) as i32; - tab.set_size = 6; tab.position_in_set = nav_index + 1; + tab.value = linux_preview_navigation_value(nav_index); + let nav_enabled: i32 = linux_preview_navigation_enabled(nav_index); + tab.disabled = (nav_enabled == 0) as i32; + if (nav_enabled != 0) { + tab.action_mask = semantic_actions_add( + semantic_actions_add(semantic_actions(SemanticAction.activate), + SemanticAction.select), SemanticAction.focus); + tab.focus_order = nav_index + 1; + } + tab.selected = (nav_index == selected_route) as i32; + tab.set_size = linux_preview_navigation_count(); + tab.position_in_set = nav_index + 1; tab.has_bounds = 1; - tab.bounds = rect(preview_semantics_x(42, width), - preview_semantics_y(129 + (nav_index as i64) * 70, height), - preview_semantics_x(176, width), preview_semantics_y(48, height)); + tab.bounds = linux_preview_navigation_item_bounds(layout, nav_index, + linux_preview_navigation_count()); _ = linux_preview_add_semantic(&tree, tab); nav_index = nav_index + 1; } let main: SemanticsSpec = semantics_spec(linux_preview_main_id(), - linux_preview_root_id(), SemanticRole.group, "Renderer performance"); - main.description = "Deterministic CPU frame-time chart and pipeline controls"; + linux_preview_root_id(), SemanticRole.group, + linux_preview_main_heading(selected_route)); + main.description = linux_preview_main_description(selected_route); main.has_bounds = 1; - main.bounds = rect(preview_semantics_x(256, width), - preview_semantics_y(104, height), preview_semantics_x(548, width), - preview_semantics_y(592, height)); + main.bounds = layout.main; _ = linux_preview_add_semantic(&tree, main); let heading: SemanticsSpec = semantics_spec(node_key(20301), - linux_preview_main_id(), SemanticRole.text, "Renderer performance"); + linux_preview_main_id(), SemanticRole.text, + linux_preview_main_heading(selected_route)); heading.has_bounds = 1; - heading.bounds = rect(preview_semantics_x(310, width), - preview_semantics_y(145, height), preview_semantics_x(260, width), - preview_semantics_y(50, height)); + heading.bounds = layout.page_heading; _ = linux_preview_add_semantic(&tree, heading); let inspect: SemanticsSpec = semantics_spec(linux_preview_inspect_id(), linux_preview_main_id(), SemanticRole.button, "Inspect renderer pipeline"); inspect.action_mask = semantic_actions_add(semantic_actions(SemanticAction.activate), SemanticAction.focus); inspect.focus_order = 7; inspect.has_bounds = 1; - inspect.bounds = rect(preview_semantics_x(650, width), - preview_semantics_y(148, height), preview_semantics_x(98, width), - preview_semantics_y(34, height)); + inspect.bounds = linux_preview_inspect_bounds(layout); _ = linux_preview_add_semantic(&tree, inspect); - let chip_index: i32 = 0; - while (chip_index < 4) { - let name: []u8 = "State"; - if (chip_index == 1) { name = "Layout"; } - else if (chip_index == 2) { name = "Motion"; } - else if (chip_index == 3) { name = "Render"; } - let chip: SemanticsSpec = semantics_spec(linux_preview_chip_id(chip_index), - linux_preview_main_id(), SemanticRole.tab, name); - chip.action_mask = semantic_actions_add(semantic_actions(SemanticAction.activate), - SemanticAction.focus); - chip.focus_order = 8 + chip_index; chip.has_bounds = 1; - chip.selected = (chip_index == 0) as i32; - chip.set_size = 4; chip.position_in_set = chip_index + 1; - if (chip_index == 3) { - chip.disabled = 1; chip.action_mask = 0; + if (layout.chart_visible != 0) { + let chip_index: i32 = 0; + while (chip_index < 4) { + let name: []u8 = "State"; + if (chip_index == 1) { name = "Layout"; } + else if (chip_index == 2) { name = "Motion"; } + else if (chip_index == 3) { name = "Render"; } + let chip: SemanticsSpec = semantics_spec( + linux_preview_chip_id(chip_index), linux_preview_main_id(), + SemanticRole.tab, name); + chip.action_mask = semantic_actions_add( + semantic_actions(SemanticAction.activate), SemanticAction.focus); + chip.focus_order = 8 + chip_index; chip.has_bounds = 1; + chip.selected = (chip_index == 0) as i32; + chip.set_size = 4; chip.position_in_set = chip_index + 1; + if (chip_index == 3) { + chip.disabled = 1; chip.action_mask = 0; + } + chip.bounds = linux_preview_segment_bounds(layout, chip_index, 4); + _ = linux_preview_add_semantic(&tree, chip); + chip_index = chip_index + 1; } - chip.bounds = rect(preview_semantics_x(284 + (chip_index as i64) * 124, width), - preview_semantics_y(530, height), preview_semantics_x(108, width), - preview_semantics_y(42, height)); - _ = linux_preview_add_semantic(&tree, chip); - chip_index = chip_index + 1; - } - let chart: SemanticsSpec = semantics_spec(linux_preview_chart_table_id(), - linux_preview_main_id(), SemanticRole.table, "CPU frame time samples"); - chart.description = "Frame time in milliseconds by sample; 16.7 millisecond deadline"; - chart.row_count = 8; chart.column_count = 2; chart.has_bounds = 1; - chart.bounds = rect(preview_semantics_x(284, width), - preview_semantics_y(224, height), preview_semantics_x(492, width), - preview_semantics_y(282, height)); - _ = linux_preview_add_semantic(&tree, chart); - let sample_index: i32 = 0; - while (sample_index < 8) { - let sample_cell: SemanticsSpec = semantics_spec( - linux_preview_chart_cell_id(sample_index * 2), - linux_preview_chart_table_id(), SemanticRole.cell, - linux_preview_chart_sample_name(sample_index)); - sample_cell.row_index = sample_index; sample_cell.column_index = 0; - sample_cell.row_span = 1; sample_cell.column_span = 1; - _ = linux_preview_add_semantic(&tree, sample_cell); - let value_cell: SemanticsSpec = semantics_spec( - linux_preview_chart_cell_id(sample_index * 2 + 1), - linux_preview_chart_table_id(), SemanticRole.cell, "Frame time"); - value_cell.value = linux_preview_chart_sample_value(sample_index); - value_cell.row_index = sample_index; value_cell.column_index = 1; - value_cell.row_span = 1; value_cell.column_span = 1; - _ = linux_preview_add_semantic(&tree, value_cell); - sample_index = sample_index + 1; + let chart: SemanticsSpec = semantics_spec(linux_preview_chart_table_id(), + linux_preview_main_id(), SemanticRole.table, "CPU frame time samples"); + chart.description = + "Frame time in milliseconds by sample; 16.7 millisecond deadline"; + chart.row_count = 8; chart.column_count = 2; chart.has_bounds = 1; + chart.bounds = layout.chart; + _ = linux_preview_add_semantic(&tree, chart); + let sample_index: i32 = 0; + while (sample_index < 8) { + let sample_cell: SemanticsSpec = semantics_spec( + linux_preview_chart_cell_id(sample_index * 2), + linux_preview_chart_table_id(), SemanticRole.cell, + linux_preview_chart_sample_name(sample_index)); + sample_cell.row_index = sample_index; sample_cell.column_index = 0; + sample_cell.row_span = 1; sample_cell.column_span = 1; + _ = linux_preview_add_semantic(&tree, sample_cell); + let value_cell: SemanticsSpec = semantics_spec( + linux_preview_chart_cell_id(sample_index * 2 + 1), + linux_preview_chart_table_id(), SemanticRole.cell, "Frame time"); + value_cell.value = linux_preview_chart_sample_value(sample_index); + value_cell.row_index = sample_index; value_cell.column_index = 1; + value_cell.row_span = 1; value_cell.column_span = 1; + _ = linux_preview_add_semantic(&tree, value_cell); + sample_index = sample_index + 1; + } + } else { + let summary: SemanticsSpec = semantics_spec( + linux_preview_compact_summary_id(), linux_preview_main_id(), + SemanticRole.text, "Frame pacing summary"); + summary.description = + "The full accessible performance chart is available at 960 by 720 and larger"; + summary.value = "P99 12.1 milliseconds; 16.7 millisecond display deadline"; + summary.has_bounds = 1; + summary.bounds = layout.compact_summary; + _ = linux_preview_add_semantic(&tree, summary); } - let activity: SemanticsSpec = semantics_spec(linux_preview_activity_id(), - linux_preview_root_id(), SemanticRole.list, "System status"); - activity.row_count = 5; activity.column_count = 1; activity.has_bounds = 1; - activity.bounds = rect(preview_semantics_x(824, width), - preview_semantics_y(104, height), preview_semantics_x(272, width), - preview_semantics_y(592, height)); - _ = linux_preview_add_semantic(&tree, activity); - let activity_index: i32 = 0; - while (activity_index < 5) { - let category: ShowcaseCategory = showcase_category_at(activity_index); - let name: []u8 = showcase_category_name(category); - let item: SemanticsSpec = semantics_spec( - linux_preview_activity_item_id(activity_index), - linux_preview_activity_id(), SemanticRole.list_item, name); - item.description = showcase_category_description(category); - item.value = showcase_category_value(category); - item.row_index = activity_index; item.row_span = 1; - item.column_index = 0; item.column_span = 1; - item.has_bounds = 1; - item.bounds = rect(preview_semantics_x(848, width), - preview_semantics_y(138 + (activity_index as i64) * 96, height), - preview_semantics_x(224, width), preview_semantics_y(74, height)); - _ = linux_preview_add_semantic(&tree, item); - activity_index = activity_index + 1; + if (layout.status_visible != 0) { + let activity: SemanticsSpec = semantics_spec(linux_preview_activity_id(), + linux_preview_root_id(), SemanticRole.list, "System status"); + activity.description = + "Read-only runtime capability and conformance status"; + activity.row_count = 5; activity.column_count = 1; + activity.has_bounds = 1; activity.bounds = layout.status; + _ = linux_preview_add_semantic(&tree, activity); + let activity_index: i32 = 0; + while (activity_index < 5) { + let category: ShowcaseCategory = showcase_category_at(activity_index); + let name: []u8 = showcase_category_name(category); + let item: SemanticsSpec = semantics_spec( + linux_preview_activity_item_id(activity_index), + linux_preview_activity_id(), SemanticRole.list_item, name); + item.description = showcase_category_description(category); + item.value = showcase_category_value(category); + item.row_index = activity_index; item.row_span = 1; + item.column_index = 0; item.column_span = 1; + item.has_bounds = 1; + item.bounds = linux_preview_status_item_bounds(layout, + activity_index, 5); + _ = linux_preview_add_semantic(&tree, item); + activity_index = activity_index + 1; + } } return tree; } diff --git a/tests/linux_preview_semantics_contract.zag b/tests/linux_preview_semantics_contract.zag index 30ccbde..6d5378b 100644 --- a/tests/linux_preview_semantics_contract.zag +++ b/tests/linux_preview_semantics_contract.zag @@ -12,27 +12,44 @@ fn expect(state: *TestState, condition: i32, name: []u8) void { fn main() i32 { let state: TestState = TestState{ .passed = 0, .failed = 0 }; - let tree: SemanticsTree = linux_preview_semantics(1120, 720, 1); + let tree: SemanticsTree = linux_preview_semantics(1120, 720, + linux_preview_initial_navigation()); + let medium_layout: LinuxPreviewLayout = linux_preview_layout(1120, 720); expect(&state, tree.last_error == SemanticsError.none && - tree.nodes.len == linux_preview_semantic_node_count(), + tree.nodes.len == linux_preview_semantic_node_count(1120, 720), "the native preview exposes one complete owned semantic tree"); let components_index: i32 = semantics_find_index(tree, linux_preview_nav_id(1)); let overview_index: i32 = semantics_find_index(tree, linux_preview_nav_id(0)); expect(&state, components_index >= 0 && overview_index >= 0 && - tree.nodes.data[components_index].selected == 1 && - tree.nodes.data[overview_index].selected == 0, + tree.nodes.data[components_index].selected == 0 && + tree.nodes.data[components_index].disabled == 1 && + tree.nodes.data[components_index].action_mask == 0 && + tree.nodes.data[overview_index].selected == 1 && + tree.nodes.data[overview_index].disabled == 0, "navigation selection truth uses stable semantic IDs"); - expect(&state, tree.nodes.data[components_index].bounds.x == 42 * unit_scale() && - tree.nodes.data[components_index].bounds.width == 176 * unit_scale(), - "semantic hit bounds match the visible Flex-aligned navigation frame"); + let expected_components: Rect = linux_preview_navigation_item_bounds( + medium_layout, 1, linux_preview_navigation_count()); + expect(&state, + tree.nodes.data[components_index].bounds.x == expected_components.x && + tree.nodes.data[components_index].bounds.y == expected_components.y && + tree.nodes.data[components_index].bounds.width == expected_components.width && + tree.nodes.data[components_index].bounds.height == expected_components.height, + "semantic hit bounds come from the shared Flex placement authority"); let session: TalkbackSession = talkback_session_make(talkback_capabilities()); let query: TalkbackResponse = talkback_dispatch(&session, tree, rect(0, 0, 1120 * unit_scale(), 720 * unit_scale()), talkback_request(1, TalkbackCommand.query, linux_preview_nav_id(1), tree.revision)); expect(&state, query.status == TalkbackStatus.accepted && - query.resolved_role == SemanticRole.tab && query.has_bounds == 1, - "Talkback queries the live preview by stable ID without pixels"); + query.resolved_role == SemanticRole.tab && query.has_bounds == 1 && + (query.state_flags & 1) != 0, + "Talkback queries the disabled planned route by stable ID without pixels"); + let disabled_click: TalkbackResponse = talkback_dispatch(&session, tree, + rect(0, 0, 1120 * unit_scale(), 720 * unit_scale()), + talkback_request(3, TalkbackCommand.click, + linux_preview_nav_id(1), tree.revision)); + expect(&state, disabled_click.status == TalkbackStatus.action_unavailable, + "unfinished navigation destinations fail closed instead of changing a highlight"); let click: TalkbackResponse = talkback_dispatch(&session, tree, rect(0, 0, 1120 * unit_scale(), 720 * unit_scale()), talkback_request(2, TalkbackCommand.click, @@ -63,18 +80,58 @@ fn main() i32 { tree.nodes.data[render_chip_index].disabled == 1 && tree.nodes.data[render_chip_index].action_mask == 0, "the stage selector has explicit selected default and disabled states"); - let resized: SemanticsTree = linux_preview_semantics(1400, 800, 1); - let resized_index: i32 = semantics_find_index(resized, linux_preview_nav_id(1)); + expect(&state, semantics_find_index(tree, linux_preview_activity_id()) < 0, + "the medium layout does not expose an invisible competing status rail"); + let resized: SemanticsTree = linux_preview_semantics(1400, 800, + linux_preview_initial_navigation()); + let expanded_layout: LinuxPreviewLayout = linux_preview_layout(1400, 800); + let resized_index: i32 = semantics_find_index(resized, linux_preview_nav_id(0)); expect(&state, resized_index >= 0 && resized.nodes.data[resized_index].bounds.width > tree.nodes.data[components_index].bounds.width && talkback_tree_hash(resized) != talkback_tree_hash(tree), "resize recomputes semantic geometry while retaining stable IDs"); - expect(&state, linux_preview_navigation_at_pixel(100, 230, 1120, 720) == 1 && - linux_preview_navigation_at_pixel(125, 256, 1400, 800) == 1 && - linux_preview_navigation_at_pixel(10, 230, 1120, 720) == 0 - 1, - "physical pointer routing resolves through the same adaptive navigation bounds"); - talkback_session_free(&session); semantics_tree_free(&resized); semantics_tree_free(&tree); + let activity_index: i32 = semantics_find_index(resized, + linux_preview_activity_id()); + expect(&state, resized.nodes.len == linux_preview_semantic_node_count(1400, 800) && + activity_index >= 0 && + resized.nodes.data[activity_index].role == SemanticRole.list && + resized.nodes.data[activity_index].bounds.x == expanded_layout.status.x, + "expanded layout exposes one explicitly read-only system-status rail"); + let overview_bounds: Rect = linux_preview_navigation_item_bounds( + medium_layout, 0, linux_preview_navigation_count()); + let component_bounds: Rect = linux_preview_navigation_item_bounds( + medium_layout, 1, linux_preview_navigation_count()); + let expanded_component_bounds: Rect = linux_preview_navigation_item_bounds( + expanded_layout, 1, linux_preview_navigation_count()); + expect(&state, linux_preview_navigation_at_pixel( + ((overview_bounds.x + overview_bounds.width / 2) / unit_scale()) as i32, + ((overview_bounds.y + overview_bounds.height / 2) / unit_scale()) as i32, + 1120, 720) == 0 && + linux_preview_navigation_hit_at_pixel( + ((component_bounds.x + component_bounds.width / 2) / unit_scale()) as i32, + ((component_bounds.y + component_bounds.height / 2) / unit_scale()) as i32, + 1120, 720) == 1 && + linux_preview_navigation_at_pixel( + ((component_bounds.x + component_bounds.width / 2) / unit_scale()) as i32, + ((component_bounds.y + component_bounds.height / 2) / unit_scale()) as i32, + 1120, 720) == 0 - 1 && + linux_preview_navigation_at_pixel( + ((expanded_component_bounds.x + expanded_component_bounds.width / 2) / + unit_scale()) as i32, + ((expanded_component_bounds.y + expanded_component_bounds.height / 2) / + unit_scale()) as i32, + 1400, 800) == 0 - 1 && + linux_preview_navigation_at_pixel(0, 0, 1120, 720) == 0 - 1, + "physical pointer routing distinguishes enabled disabled and outside bounds"); + let compact: SemanticsTree = linux_preview_semantics(800, 600, + linux_preview_initial_navigation()); + expect(&state, compact.nodes.len == linux_preview_semantic_node_count(800, 600) && + semantics_find_index(compact, linux_preview_chart_table_id()) < 0 && + semantics_find_index(compact, linux_preview_compact_summary_id()) >= 0, + "compact semantics replace the unfittable chart with an honest summary"); + talkback_session_free(&session); semantics_tree_free(&compact); + semantics_tree_free(&resized); semantics_tree_free(&tree); _zag_print("Linux preview semantics: pass="); _zag_print_i64(state.passed as i64); _zag_print(" fail="); _zag_print_i64(state.failed as i64); _zag_println(""); if (state.failed == 0) { return 0; } From bac62a36913bdb20735c30111cc6807db16a58c4 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 06:58:03 -0700 Subject: [PATCH 092/103] Add inspectable showcase material tokens --- src/design/tokens.zag | 107 +++++++++++++++++++++++++++++++ tests/design_tokens_contract.zag | 23 +++++++ 2 files changed, 130 insertions(+) diff --git a/src/design/tokens.zag b/src/design/tokens.zag index 67efbac..e81afbb 100644 --- a/src/design/tokens.zag +++ b/src/design/tokens.zag @@ -187,6 +187,113 @@ fn semantic_elevation(token: SemanticElevationToken) ResolvedElevationToken { }; } +enum SemanticMaterialToken { + showcase_backdrop, + shell, + panel, + raised, + overlay, +} + +struct ResolvedMaterialToken { + id: []u8, + fill_token: SemanticColorToken, + tint_token: SemanticColorToken, + edge_token: SemanticColorToken, + radius_token: SemanticRadiusToken, + elevation_token: SemanticElevationToken, + tint_alpha_start: i64, + tint_alpha_end: i64, + blur_radius: i64, + cpu_fallback_reason: []u8, +} + +fn semantic_material_token_count() i32 { return 5; } + +fn semantic_material(token: SemanticMaterialToken) ResolvedMaterialToken { + return switch (token) { + .showcase_backdrop => ResolvedMaterialToken{ + .id = "material.showcase.backdrop", + .fill_token = SemanticColorToken.canvas, + .tint_token = SemanticColorToken.ambient_cool, + .edge_token = SemanticColorToken.border_subtle, + .radius_token = SemanticRadiusToken.control, + .elevation_token = SemanticElevationToken.base, + .tint_alpha_start = 1500, .tint_alpha_end = 12500, + .blur_radius = 0, + .cpu_fallback_reason = "retained color bands are the deterministic CPU gradient oracle", + }, + .shell => ResolvedMaterialToken{ + .id = "material.glass.shell", + .fill_token = SemanticColorToken.surface_base, + .tint_token = SemanticColorToken.ambient_cool, + .edge_token = SemanticColorToken.edge_highlight, + .radius_token = SemanticRadiusToken.panel, + .elevation_token = SemanticElevationToken.base, + .tint_alpha_start = 0, .tint_alpha_end = 0, + .blur_radius = 18 * unit_scale(), + .cpu_fallback_reason = "translucent fill and edge remain authoritative until backdrop blur lands", + }, + .panel => ResolvedMaterialToken{ + .id = "material.glass.panel", + .fill_token = SemanticColorToken.surface_panel, + .tint_token = SemanticColorToken.ambient_cool, + .edge_token = SemanticColorToken.edge_highlight, + .radius_token = SemanticRadiusToken.panel, + .elevation_token = SemanticElevationToken.panel, + .tint_alpha_start = 0, .tint_alpha_end = 0, + .blur_radius = 24 * unit_scale(), + .cpu_fallback_reason = "translucent fill edge and shadow are the current CPU material", + }, + .raised => ResolvedMaterialToken{ + .id = "material.glass.raised", + .fill_token = SemanticColorToken.surface_raised, + .tint_token = SemanticColorToken.ambient_cool, + .edge_token = SemanticColorToken.edge_highlight, + .radius_token = SemanticRadiusToken.card, + .elevation_token = SemanticElevationToken.raised, + .tint_alpha_start = 0, .tint_alpha_end = 0, + .blur_radius = 30 * unit_scale(), + .cpu_fallback_reason = "translucent fill edge and raised shadow are the current CPU material", + }, + .overlay => ResolvedMaterialToken{ + .id = "material.glass.overlay", + .fill_token = SemanticColorToken.surface_raised, + .tint_token = SemanticColorToken.ambient_warm, + .edge_token = SemanticColorToken.edge_highlight, + .radius_token = SemanticRadiusToken.card, + .elevation_token = SemanticElevationToken.overlay, + .tint_alpha_start = 0, .tint_alpha_end = 0, + .blur_radius = 36 * unit_scale(), + .cpu_fallback_reason = "translucent fill edge and overlay shadow are the current CPU material", + }, + }; +} + +fn semantic_material_band_paint(token: SemanticMaterialToken, + band: i32, band_count: i32) Paint { + let material: ResolvedMaterialToken = semantic_material(token); + let fill: Paint = semantic_color_paint(material.fill_token); + let tint: Paint = semantic_color_paint(material.tint_token); + let denominator: i64 = 1; + let index: i64 = 0; + if (band_count > 1) { + denominator = (band_count - 1) as i64; + index = band as i64; + if (index < 0) { index = 0; } + if (index > denominator) { index = denominator; } + } + let alpha: i64 = material.tint_alpha_start + + ((material.tint_alpha_end - material.tint_alpha_start) * index) / + denominator; + let inverse: i64 = 65535 - alpha; + return paint_rgba16( + (fill.red * inverse + tint.red * alpha) / 65535, + (fill.green * inverse + tint.green * alpha) / 65535, + (fill.blue * inverse + tint.blue * alpha) / 65535, + fill.alpha); +} + enum ShowcaseCategory { frame_pacing, semantics, motion, input, renderer } fn showcase_category_at(index: i32) ShowcaseCategory { diff --git a/tests/design_tokens_contract.zag b/tests/design_tokens_contract.zag index 945eedd..4376067 100644 --- a/tests/design_tokens_contract.zag +++ b/tests/design_tokens_contract.zag @@ -57,6 +57,29 @@ fn main() i32 { panel.shadow_alpha < raised.shadow_alpha && raised.shadow_alpha < overlay.shadow_alpha, "base panel raised and overlay elevations have ordered depth"); + let shell_material: ResolvedMaterialToken = semantic_material( + SemanticMaterialToken.shell); + let raised_material: ResolvedMaterialToken = semantic_material( + SemanticMaterialToken.raised); + let overlay_material: ResolvedMaterialToken = semantic_material( + SemanticMaterialToken.overlay); + expect(&state, semantic_material_token_count() == 5 && + bytes_equal(shell_material.id, "material.glass.shell") != 0 && + shell_material.elevation_token == SemanticElevationToken.base && + raised_material.elevation_token == SemanticElevationToken.raised && + overlay_material.elevation_token == SemanticElevationToken.overlay && + shell_material.fill_token != raised_material.fill_token, + "glass materials bind inspectable fill radius depth and fallback rules"); + let backdrop_first: Paint = semantic_material_band_paint( + SemanticMaterialToken.showcase_backdrop, 0, 48); + let backdrop_last: Paint = semantic_material_band_paint( + SemanticMaterialToken.showcase_backdrop, 47, 48); + expect(&state, paint_equal(backdrop_first, backdrop_last) == 0 && + backdrop_first.alpha == backdrop_last.alpha && + bytes_equal(semantic_material( + SemanticMaterialToken.showcase_backdrop).id, + "material.showcase.backdrop") != 0, + "backdrop bands vary through one named material instead of local colors"); expect(&state, bytes_equal(showcase_category_description( ShowcaseCategory.frame_pacing), showcase_category_description( ShowcaseCategory.semantics)) == 0 && From 34a95291bf1bf2c527588a451f991df93b4f1a54 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 06:58:15 -0700 Subject: [PATCH 093/103] Require shared responsive showcase placement --- GOAL.md | 2 +- docs/design/showcase-conformance.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/GOAL.md b/GOAL.md index 713a417..159d0f2 100644 --- a/GOAL.md +++ b/GOAL.md @@ -101,7 +101,7 @@ more precision is needed and preserve its original ID as the parent. - [ ] `G5-LINUX-GPU` Implement one explicit public Linux GPU transport. — Exit: opt-in physical-device execution, loss recovery, CPU comparison, and cleanup pass - [ ] `G5-LINUX-POLISH` Make Linux the first no-rough-edges reference experience. — Exit: no known severity-one or severity-two visual, input, text, accessibility, recovery, or packaging defects remain - [ ] `G5-LINUX-FIDELITY` Eliminate density-dependent and pixelated UI output. — Exit: screenshot comparisons pass at 1.0, 1.25, 1.5, 2.0, and 3.0 scales with crisp type, SVG, PNG, curves, glass, and shadows -- [ ] `G5-SHOWCASE-CONFORMANCE` Make the Linux showcase prove a coherent reusable system rather than a one-off dashboard. — Exit: [showcase conformance](docs/design/showcase-conformance.md) passes token provenance, semantic icon/color mapping, three elevation tiers, full canonical interaction states, type ramp, unambiguous navigation/status roles, real segmented-control behavior, and accessible chart anatomy with native screenshots and semantic evidence +- [ ] `G5-SHOWCASE-CONFORMANCE` Make the Linux showcase prove a coherent reusable system rather than a one-off dashboard. — Exit: [showcase conformance](docs/design/showcase-conformance.md) passes token provenance, semantic icon/color mapping, three elevation tiers, full canonical interaction states, type ramp, unambiguous navigation/status roles, real segmented-control behavior, accessible chart anatomy, and one shared Flex placement authority for pixels, semantics, hit targets, and Talkback across expanded, medium, compact, and unsupported-size states, with native screenshots and semantic evidence - [ ] `G5-LINUX-PACKAGE` Package, install, launch, update, and uninstall Linux artifacts. — Exit: Ubuntu LTS and Fedora x86-64 and ARM64 release matrices pass where target support is declared ## 6. Complete PrismStudio overhaul diff --git a/docs/design/showcase-conformance.md b/docs/design/showcase-conformance.md index 4a0c4ed..c8c629c 100644 --- a/docs/design/showcase-conformance.md +++ b/docs/design/showcase-conformance.md @@ -66,6 +66,23 @@ but it must be labeled experimental and cannot promote the visual system. remains visibly and semantically disabled; changing only its highlight is a failing no-op, not navigation evidence. +### Responsive placement + +- Rendering, semantics, hit testing, and Talkback consume one shared Flex + placement result. Independently scaled coordinate tables are a conformance + failure even when one screenshot happens to align. +- Component minimums are hard constraints. In particular, the performance + chart is never squeezed below 640 by 420 logical pixels to preserve a + dashboard silhouette. +- Expanded placement may expose the labelled system-status rail. Medium + placement releases that width to primary content, and compact placement + changes navigation structure rather than shrinking desktop columns. +- In every valid desktop viewport, if the chart contract cannot fit, the page + exposes an explicit compact semantic summary and removes chart-only controls. + The desktop placement contract defines a 480 by 360 minimum; the Linux shell + must show an explicit unsupported-size state below it before promotion. A + cropped, overlapped, or silently incomplete chart is not adaptive evidence. + ## Evidence matrix For each required component and state, record the semantic token trace, From d720d82f70a97b1ad51a6b0b015950605b1eb9b4 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 07:00:54 -0700 Subject: [PATCH 094/103] Add bounded OpenType Text component --- contracts/components.json | 2 +- docs/architecture/text.md | 31 +- docs/components/text.md | 144 +++++++ src/components/text.zag | 632 +++++++++++++++++++++++++++++ src/text/opentype.zag | 14 +- tests/opentype_contract.zag | 15 +- tests/opentype_fuzz.zag | 2 + tests/support/opentype_fixture.zag | 2 + tests/text_contract.zag | 451 ++++++++++++++++++++ tools/test-text.sh | 18 + 10 files changed, 1297 insertions(+), 14 deletions(-) create mode 100644 docs/components/text.md create mode 100644 src/components/text.zag create mode 100644 tests/text_contract.zag create mode 100755 tools/test-text.sh diff --git a/contracts/components.json b/contracts/components.json index 624a5c9..40f3f2d 100644 --- a/contracts/components.json +++ b/contracts/components.json @@ -20,7 +20,7 @@ "rule": "Visual component production cannot begin until visual direction and accessibility review are accepted." }, "components": [ - {"id":"text","family":"content","name":"Text","milestone":2,"status":"planned","semantic_roles":["text"],"inputs":[],"adaptive":["locale","direction","text_scale"]}, + {"id":"text","family":"content","name":"Text","milestone":2,"status":"implementing","semantic_roles":["text"],"inputs":[],"adaptive":["semantic_type_token","semantic_color_token"],"source":"src/components/text.zag","evidence":"tests/text_contract.zag","documentation":"docs/components/text.md"}, {"id":"rich-text","family":"content","name":"RichText","milestone":2,"status":"planned","semantic_roles":["text","link"],"inputs":["pointer","keyboard","touch"],"adaptive":["locale","direction","text_scale"]}, {"id":"image","family":"content","name":"Image","milestone":2,"status":"planned","semantic_roles":["image"],"inputs":[],"adaptive":["density","contrast"]}, {"id":"icon","family":"content","name":"Icon","milestone":2,"status":"planned","semantic_roles":["image","none"],"inputs":[],"adaptive":["density","contrast","direction"]}, diff --git a/docs/architecture/text.md b/docs/architecture/text.md index 98392fb..9dd2c83 100644 --- a/docs/architecture/text.md +++ b/docs/architecture/text.md @@ -1,7 +1,9 @@ # Text engine -Status: **early headless foundation**. Text input and visible glyph rendering -remain unavailable in the platform capability matrix. +Status: **early headless foundation with an implementing Text component**. +Bounded nominal-LTR glyph paths now render through the headless CPU oracle. +General product typography, text input, and native platform integration remain +unavailable in the platform capability matrix. Zagkit owns the complete text pipeline. System font files and published Unicode/OpenType data are inputs; FreeType, HarfBuzz, Skia, browser text, and @@ -15,7 +17,8 @@ native widget text engines are not runtime dependencies. and values above `U+10FFFF` fail at an explicit byte offset. - `src/text/opentype.zag` copies and owns bounded SFNT/OpenType bytes, validates the table directory and required `cmap`, `head`, `maxp`, `hhea`, and `hmtx` - tables, and exposes `unitsPerEm`, glyph-count, and horizontal-advance truth. + tables, and exposes `unitsPerEm`, glyph-count, horizontal advances, ascender, + descender, and line-gap truth. - TrueType `loca`, simple `glyf` contours, and recursively bounded composite components decode into exact font-unit points with explicit on-curve flags and contour ends. Empty glyphs @@ -35,6 +38,12 @@ native widget text engines are not runtime dependencies. drift-free fixed-point origins, and the positioned contours assemble into one immutable path executable by the CPU oracle. Missing coverage returns a fallback request instead of a fabricated glyph. +- `src/components/text.zag` composes that nominal run into one sealed Canvas + path resource with exact bounds and clipping, semantic type and color token + provenance, OpenType-derived baseline metrics, start/center/end placement, + explicit reject-or-clip overflow, and owned or parent-owned semantics. Its + parent-owned mode renders every PerformanceChart text slot without creating + duplicate chart semantics. See the [bounded Text contract](../components/text.md). - The CPU path oracle indexes active edges per subpixel scanline, preserving exact eight-by-eight coverage while avoiding all-edges-per-pixel sentence work. It preflights the actual bounded scanline work before touching pixels. @@ -44,10 +53,10 @@ native widget text engines are not runtime dependencies. metrics, unsupported mappings, invalid codepoints, and glyph IDs outside the declared font. -## Required before text is visible +## Required before general typography is available -1. Parse vertical metrics, CFF/CFF2 outlines, variation axes, color-glyph - tables, and font metadata. +1. Parse CFF/CFF2 outlines, variation axes, color-glyph tables, and font + metadata beyond the current required metric subset. 2. Implement normalization, grapheme/word/sentence segmentation, script runs, bidi resolution, line breaking, fallback, and locale-aware shaping. The nominal LTR stage is not a substitute for any of these operations. @@ -55,11 +64,13 @@ native widget text engines are not runtime dependencies. glyph-run serialization. 4. Promote shaped glyph runs to a canonical render resource and define the subpixel antialiasing and color-space policy used by the CPU oracle. -5. Connect glyph runs to intrinsic measurement, selection, editing, semantics, - IME composition, accessibility text navigation, Talkback, and replay. +5. Connect shaped runs to intrinsic measurement, selection, editing, IME + composition, accessibility text navigation, and replay. The bounded Text + slice proves only basic whole-node semantics and in-process Talkback query. 6. Prove representative Latin, Arabic, Hebrew, Indic, CJK, emoji, combining, malformed-font, RTL, large-text, and fallback suites before changing the platform `text_input` capability from unavailable. -Placeholder bars in the Linux preview are not typography evidence and must be -removed once the owned glyph path is available. +Placeholder bars in the Linux preview are not typography evidence. Replacing +them requires explicit host composition of this Text component; their presence +does not count as a text backend or platform-typography result. diff --git a/docs/components/text.md b/docs/components/text.md new file mode 100644 index 0000000..9a43c9a --- /dev/null +++ b/docs/components/text.md @@ -0,0 +1,144 @@ +# Text + +`Text` is an experimental, bounded, single-line composition component. It +turns strict UTF-8 into one nominal left-to-right glyph run using Zagkit's +owned OpenType parser, converts every TrueType outline through the owned glyph +path pipeline, and publishes the whole run as one immutable path resource in a +sealed `Canvas`. There is no shell text helper, OS text API, FreeType, +HarfBuzz, Skia, browser engine, or foreign shaping library in this path. + +The component inventory status is `implementing`. The focused contract is real +headless CPU evidence, but this is not general product typography and does not +raise any platform text capability to supported. + +## Authoring contract + +Create a `TextSpec` with: + +```zag +let spec: TextSpec = text_spec( + node_key(42), semantic_parent, exact_bounds, "Status", + SemanticTypeToken.label, SemanticColorToken.text_primary +); +``` + +The stable `NodeKey`, semantic parent, exact positive bounds, nonempty text, +semantic type token, and semantic color token are required. Text is limited to +4,096 UTF-8 bytes and 1,024 decoded scalars. UTF-8 must be canonical and +NUL-free. Missing font coverage, malformed input, unsupported outlines, and +empty combined outline paths fail explicitly; the component never fabricates +a replacement glyph. + +`TextAlignment.start`, `.center`, and `.end` place the measured advance inside +the exact bounds. These are physical nominal-LTR positions in this bounded +slice, not bidi-aware logical alignment. + +`TextOverflowPolicy.reject` fails before publishing a resource when the +typographic advance or line height exceeds the bounds. `.clip` retains the +same metrics, reports `artifact.clipped = 1`, and uses the exact `TextSpec` +bounds as the Canvas clip. It does not silently resize text or bounds. + +## Metrics and token provenance + +The font size is resolved only through `SemanticTypeToken`; there is no +untracked literal-size override. The paint is resolved only through +`SemanticColorToken`. A successful artifact records both enum values and their +stable IDs, such as `type.caption` and `color.text.primary`. + +The line metrics are deterministic fixed-point values: + +```text +ascender = hhea.ascender * token_size / units_per_em +descender = hhea.descender * token_size / units_per_em +line_gap = hhea.lineGap * token_size / units_per_em +line_height = ascender - descender + line_gap +baseline = bounds.y + (bounds.height - line_height) / 2 + + line_gap / 2 + ascender +``` + +Integer division follows Zag's deterministic integer rules. Horizontal glyph +origins come from cumulative font-unit advances, so rounding cannot drift per +glyph. + +The artifact also exposes exact advance, origin, baseline, scalar and glyph +counts, a path count fixed to one, combined path-command count, path resource +ID, clipping truth, exact bounds, and a content evidence hash. The Canvas owns +a copy of the encoded path bytes and seals its resource store and display list +before build success. + +## Semantics ownership + +`TextSemanticsMode.own` contributes one `SemanticRole.text` node with the same +stable ID as the Canvas, copied text, scalar count, disabled state, semantic +parent, and exact bounds. Zagkit Talkback therefore queries the same ID and +bounds that identify the visible glyph path. + +`TextSemanticsMode.parent_owned` contributes display content only. This is for +components that already own richer semantics for the same visible string. It +does not add a hidden or duplicate text node. + +`PerformanceChart` uses this mode. Every `PerformanceChartTextSlot` already +contains the stable ID, owned text, exact bounds, type token, and color token +needed by `text_spec`. The chart's semantic node with that slot ID supplies the +semantic parent: + +```zag +let semantic_index: i32 = semantics_find_index(chart.semantics, slot.id); +let spec: TextSpec = text_spec( + slot.id, + chart.semantics.nodes.data[semantic_index].parent, + slot.bounds, + slot.text.data[0..slot.text.len], + slot.type_token, + slot.color_token +); +spec.semantics_mode = TextSemanticsMode.parent_owned; +``` + +That mapping applies to title, axis name and unit, tick, legend, baseline, and +deadline slots without duplicating the chart's semantic hierarchy or table +representation. + +## Caller-owned contribution and failure atomicity + +`text_contribute` accepts mutable caller-owned `DisplayList` and +`SemanticsTree` builders. It validates the sealed artifact, semantic parent and +ID, destination capacity, and resource identity before publication. Display +work is applied to a staged owned copy. Only after optional semantics succeed +does the function replace the caller display list. + +Duplicate semantic IDs, missing semantic parents, sealed or malformed display +destinations, and path-resource collisions leave both caller-owned builders +unchanged. Disabled owned text follows the same validation path; disabled state +never bypasses duplicate or parent checks. Resource identity is deterministically +namespaced by the Text `NodeKey`, so two visual contributions using the same ID +correctly collide instead of aliasing bytes. + +Call `text_free` for every build result, including failed results. It releases +the owned UTF-8 copy and the Canvas-owned display/resource storage. Caller +display lists, semantic trees, Talkback sessions, and CPU raster results retain +their existing explicit cleanup functions. + +## Evidence and limits + +Run: + +```sh +./tools/test-text.sh +``` + +The focused contract uses Zagkit's synthetic TrueType fixture. It covers strict +input rejection, OpenType-derived baseline metrics, all alignments, semantic +type hierarchy, semantic color provenance, reject and clip overflow, one +combined immutable path, exact clips, owned Text semantics, stable-ID Talkback +querying, chart-compatible parent-owned visual mode, disabled duplicate and +missing-parent atomicity, resource collisions, deterministic CPU pixels, and +cleanup. + +This implementation does **not** provide OpenType shaping, GSUB/GPOS, kerning, +normalization, grapheme-aware editing, script itemization, bidirectional text, +logical RTL alignment, wrapping, line breaking, font fallback, CFF/CFF2, +variation axes, color glyphs, locale handling, editable selection, or native +accessibility adapters. It is nominal LTR TrueType outline composition only. +Those exclusions are architectural boundaries, not implied future behavior of +the current artifact. diff --git a/src/components/text.zag b/src/components/text.zag new file mode 100644 index 0000000..5cd1873 --- /dev/null +++ b/src/components/text.zag @@ -0,0 +1,632 @@ +@import("std:list") +@import("canvas.zag") +@import("../design/tokens.zag") +@import("../text/glyph_run.zag") + +// Experimental, bounded, single-line text composition. This component uses +// Zagkit's nominal LTR glyph run and TrueType path pipeline. It deliberately +// does not claim shaping, bidi, fallback, normalization, or wrapping. + +enum TextAlignment { + start, + center, + end, +} + +enum TextOverflowPolicy { + reject, + clip, +} + +enum TextSemanticsMode { + own, + parent_owned, +} + +enum TextError { + none, + invalid_spec, + invalid_text, + invalid_font, + overflow, + glyph_run_error, + path_error, + canvas_error, +} + +enum TextContributionError { + none, + invalid_artifact, + invalid_destination, + missing_semantic_parent, + duplicate_semantic_id, + display_error, + semantics_error, +} + +struct TextSpec { + id: NodeKey, + semantic_parent: NodeKey, + bounds: Rect, + text: []u8, + type_token: SemanticTypeToken, + color_token: SemanticColorToken, + alignment: TextAlignment, + overflow: TextOverflowPolicy, + semantics_mode: TextSemanticsMode, + disabled: i32, +} + +struct TextArtifact { + canvas: Canvas, + text: ArrayList[u8], + semantic_parent: NodeKey, + bounds: Rect, + type_token: SemanticTypeToken, + color_token: SemanticColorToken, + type_token_id: []u8, + color_token_id: []u8, + alignment: TextAlignment, + overflow: TextOverflowPolicy, + semantics_mode: TextSemanticsMode, + disabled: i32, + font_size: i64, + ascender: i64, + descender: i64, + line_gap: i64, + line_height: i64, + origin_x: i64, + baseline: i64, + advance: i64, + scalar_count: i64, + glyph_count: i32, + path_count: i32, + path_command_count: i32, + resource_id: i64, + clipped: i32, + error: TextError, + glyph_error: GlyphRunError, + path_error: PathError, + canvas_error: CanvasError, + evidence_hash: i64, +} + +struct TextContributionResult { + error: TextContributionError, + canvas_error: CanvasError, + display_error: DisplayError, + semantics_error: SemanticsError, + display_operations_added: i32, + display_resources_added: i32, + semantic_nodes_added: i32, +} + +struct TextDisplayStage { + display: DisplayList, + valid: i32, +} + +fn text_byte_limit() i32 { return 4096; } +fn text_glyph_limit() i64 { return 1024; } +fn text_local_path_resource_id() i64 { return 1; } + +fn text_spec(id: NodeKey, semantic_parent: NodeKey, bounds: Rect, + text: []u8, type_token: SemanticTypeToken, + color_token: SemanticColorToken) TextSpec { + return TextSpec{ + .id = id, + .semantic_parent = semantic_parent, + .bounds = bounds, + .text = text, + .type_token = type_token, + .color_token = color_token, + .alignment = TextAlignment.start, + .overflow = TextOverflowPolicy.reject, + .semantics_mode = TextSemanticsMode.own, + .disabled = 0, + }; +} + +fn text_copy_bytes(bytes: []u8) ArrayList[u8] { + let owned: ArrayList[u8] = make[u8](bytes.len); + let i: i32 = 0; + while (i < bytes.len) { + push[u8](&owned, bytes[i]); + i = i + 1; + } + return owned; +} + +fn text_slice_hash(hash: i64, bytes: []u8) i64 { + let result: i64 = canvas_mix(hash, bytes.len as i64); + let i: i32 = 0; + while (i < bytes.len) { + result = canvas_mix(result, bytes[i] as i64); + i = i + 1; + } + return result; +} + +fn text_limits() CanvasLimits { + let limits: CanvasLimits = canvas_limits_default(); + limits.max_operations = 1; + limits.max_resources = 1; + limits.max_total_resource_bytes = 4194304; + limits.max_resource_bytes = 4194304; + return limits; +} + +fn text_canvas(spec: TextSpec) Canvas { + let canvas_specification: CanvasSpec = canvas_spec( + spec.id, spec.bounds, CanvasSemanticsPolicy.decorative, ""); + canvas_specification.semantic_parent = spec.semantic_parent; + canvas_specification.clip = spec.bounds; + canvas_specification.has_clip = 1; + canvas_specification.enabled = (spec.disabled == 0) as i32; + return canvas_make(canvas_specification, text_limits()); +} + +fn text_empty_artifact(spec: TextSpec) TextArtifact { + return TextArtifact{ + .canvas = text_canvas(spec), + .text = text_copy_bytes(""), + .semantic_parent = spec.semantic_parent, + .bounds = spec.bounds, + .type_token = spec.type_token, + .color_token = spec.color_token, + .type_token_id = "", + .color_token_id = "", + .alignment = spec.alignment, + .overflow = spec.overflow, + .semantics_mode = spec.semantics_mode, + .disabled = spec.disabled, + .font_size = 0, + .ascender = 0, + .descender = 0, + .line_gap = 0, + .line_height = 0, + .origin_x = 0, + .baseline = 0, + .advance = 0, + .scalar_count = 0, + .glyph_count = 0, + .path_count = 0, + .path_command_count = 0, + .resource_id = 0, + .clipped = 0, + .error = TextError.none, + .glyph_error = GlyphRunError.none, + .path_error = PathError.none, + .canvas_error = CanvasError.none, + .evidence_hash = 0, + }; +} + +fn text_enum_spec_valid(spec: TextSpec) i32 { + let alignment: i64 = spec.alignment as i64; + let overflow: i64 = spec.overflow as i64; + let semantics_mode: i64 = spec.semantics_mode as i64; + let type_token: i64 = spec.type_token as i64; + let color_token: i64 = spec.color_token as i64; + return (alignment >= TextAlignment.start as i64 && + alignment <= TextAlignment.end as i64 && + overflow >= TextOverflowPolicy.reject as i64 && + overflow <= TextOverflowPolicy.clip as i64 && + semantics_mode >= TextSemanticsMode.own as i64 && + semantics_mode <= TextSemanticsMode.parent_owned as i64 && + type_token >= 0 && type_token < semantic_type_token_count() as i64 && + color_token >= 0 && color_token < semantic_color_token_count() as i64 && + (spec.disabled == 0 || spec.disabled == 1)) as i32; +} + +fn text_font_valid(face: OpenTypeFace) i32 { + let line_units: i64 = (face.ascender as i64) - + (face.descender as i64) + (face.line_gap as i64); + return (face.error == OpenTypeError.none && face.units_per_em > 0 && + face.glyph_count > 0 && face.ascender > face.descender && + line_units > 0) as i32; +} + +fn text_spec_geometry_valid(spec: TextSpec) i32 { + return (canvas_key_valid(spec.id) != 0 && + canvas_parent_valid(spec.semantic_parent, semantic_root_key()) != 0 && + hit_geometry_valid(spec.bounds) != 0) as i32; +} + +fn text_aligned_origin(spec: TextSpec, advance: i64) i64 { + if (spec.alignment == TextAlignment.center) { + return spec.bounds.x + (spec.bounds.width - advance) / 2; + } + if (spec.alignment == TextAlignment.end) { + return spec.bounds.x + spec.bounds.width - advance; + } + return spec.bounds.x; +} + +fn text_artifact_hash(artifact: TextArtifact) i64 { + let hash: i64 = canvas_mix(17, artifact.canvas.key.value); + hash = canvas_mix(hash, artifact.canvas.key.generation); + hash = canvas_mix(hash, artifact.semantic_parent.value); + hash = canvas_mix(hash, artifact.semantic_parent.generation); + hash = canvas_mix(hash, artifact.bounds.x); + hash = canvas_mix(hash, artifact.bounds.y); + hash = canvas_mix(hash, artifact.bounds.width); + hash = canvas_mix(hash, artifact.bounds.height); + hash = canvas_text_hash(hash, artifact.text); + hash = canvas_mix(hash, artifact.type_token as i64); + hash = canvas_mix(hash, artifact.color_token as i64); + hash = text_slice_hash(hash, artifact.type_token_id); + hash = text_slice_hash(hash, artifact.color_token_id); + hash = canvas_mix(hash, artifact.alignment as i64); + hash = canvas_mix(hash, artifact.overflow as i64); + hash = canvas_mix(hash, artifact.semantics_mode as i64); + hash = canvas_mix(hash, artifact.disabled as i64); + hash = canvas_mix(hash, artifact.font_size); + hash = canvas_mix(hash, artifact.ascender); + hash = canvas_mix(hash, artifact.descender); + hash = canvas_mix(hash, artifact.line_gap); + hash = canvas_mix(hash, artifact.line_height); + hash = canvas_mix(hash, artifact.origin_x); + hash = canvas_mix(hash, artifact.baseline); + hash = canvas_mix(hash, artifact.advance); + hash = canvas_mix(hash, artifact.scalar_count); + hash = canvas_mix(hash, artifact.glyph_count as i64); + hash = canvas_mix(hash, artifact.path_count as i64); + hash = canvas_mix(hash, artifact.path_command_count as i64); + hash = canvas_mix(hash, artifact.resource_id); + hash = canvas_mix(hash, artifact.clipped as i64); + return canvas_mix(hash, artifact.canvas.content_hash); +} + +fn text_build(face: OpenTypeFace, spec: TextSpec) TextArtifact { + let artifact: TextArtifact = text_empty_artifact(spec); + if (text_spec_geometry_valid(spec) == 0 || text_enum_spec_valid(spec) == 0 || + artifact.canvas.last_error != CanvasError.none) { + artifact.error = TextError.invalid_spec; + artifact.canvas_error = artifact.canvas.last_error; + return artifact; + } + if (spec.text.len <= 0 || spec.text.len > text_byte_limit() || + semantic_text_input_valid(spec.text) == 0) { + artifact.error = TextError.invalid_text; + return artifact; + } + let validation: Utf8ValidationResult = utf8_validate(spec.text); + if (validation.error != Utf8Error.none || validation.scalar_count <= 0 || + validation.scalar_count > text_glyph_limit()) { + artifact.error = TextError.invalid_text; + return artifact; + } + if (text_font_valid(face) == 0) { + artifact.error = TextError.invalid_font; + return artifact; + } + + artifact.font_size = semantic_type_size(spec.type_token); + artifact.ascender = ((face.ascender as i64) * artifact.font_size) / + (face.units_per_em as i64); + artifact.descender = ((face.descender as i64) * artifact.font_size) / + (face.units_per_em as i64); + artifact.line_gap = ((face.line_gap as i64) * artifact.font_size) / + (face.units_per_em as i64); + artifact.line_height = artifact.ascender - artifact.descender + + artifact.line_gap; + if (artifact.font_size <= 0 || artifact.line_height <= 0) { + artifact.error = TextError.invalid_font; + return artifact; + } + + let run: NominalGlyphRun = nominal_glyph_run_ltr( + face, spec.text, artifact.font_size, 0, 0); + if (run.error != GlyphRunError.none || run.glyphs.len <= 0) { + artifact.error = TextError.glyph_run_error; + artifact.glyph_error = run.error; + nominal_glyph_run_free(&run); + return artifact; + } + artifact.advance = run.total_advance; + artifact.scalar_count = validation.scalar_count; + artifact.glyph_count = run.glyphs.len; + artifact.origin_x = text_aligned_origin(spec, artifact.advance); + let line_offset: i64 = (spec.bounds.height - artifact.line_height) / 2; + artifact.baseline = spec.bounds.y + line_offset + + artifact.line_gap / 2 + artifact.ascender; + let overflows: i32 = (artifact.advance > spec.bounds.width || + artifact.line_height > spec.bounds.height) as i32; + artifact.clipped = overflows; + if (overflows != 0 && spec.overflow == TextOverflowPolicy.reject) { + artifact.error = TextError.overflow; + nominal_glyph_run_free(&run); + return artifact; + } + if (path_fixed_valid(artifact.origin_x) == 0 || + path_fixed_valid(artifact.baseline) == 0) { + artifact.error = TextError.invalid_spec; + nominal_glyph_run_free(&run); + return artifact; + } + + let glyph_index: i32 = 0; + while (glyph_index < run.glyphs.len) { + run.glyphs.data[glyph_index].origin_x = + run.glyphs.data[glyph_index].origin_x + artifact.origin_x; + run.glyphs.data[glyph_index].origin_y = artifact.baseline; + glyph_index = glyph_index + 1; + } + let run_path: GlyphRunPathResult = nominal_glyph_run_path(face, run); + if (run_path.error != GlyphRunError.none) { + artifact.error = TextError.glyph_run_error; + artifact.glyph_error = run_path.error; + glyph_run_path_result_free(&run_path); + nominal_glyph_run_free(&run); + return artifact; + } + artifact.path_count = 1; + artifact.path_command_count = run_path.path.commands.len; + let payload: PathEncodeResult = path_encode(run_path.path); + if (payload.error != PathError.none) { + artifact.error = TextError.path_error; + artifact.path_error = payload.error; + path_encode_free(&payload); + glyph_run_path_result_free(&run_path); + nominal_glyph_run_free(&run); + return artifact; + } + let resource: CanvasResourceSpec = canvas_resource_spec( + text_local_path_resource_id(), RenderResourceKind.path, + payload.bytes.data[0..payload.bytes.len], path_resource_format_tag()); + let canvas_resource_error: CanvasError = canvas_add_resource( + &artifact.canvas, resource); + if (canvas_resource_error != CanvasError.none) { + artifact.error = TextError.canvas_error; + artifact.canvas_error = canvas_resource_error; + path_encode_free(&payload); + glyph_run_path_result_free(&run_path); + nominal_glyph_run_free(&run); + return artifact; + } + let draw: DisplayOp = canvas_resource_op( + artifact.canvas, DisplayOpKind.draw_path, + text_local_path_resource_id()); + draw.paint = semantic_color_paint(spec.color_token); + let canvas_draw_error: CanvasError = canvas_push(&artifact.canvas, draw); + if (canvas_draw_error != CanvasError.none) { + artifact.error = TextError.canvas_error; + artifact.canvas_error = canvas_draw_error; + path_encode_free(&payload); + glyph_run_path_result_free(&run_path); + nominal_glyph_run_free(&run); + return artifact; + } + let canvas_seal_error: CanvasError = canvas_seal(&artifact.canvas); + if (canvas_seal_error != CanvasError.none) { + artifact.error = TextError.canvas_error; + artifact.canvas_error = canvas_seal_error; + path_encode_free(&payload); + glyph_run_path_result_free(&run_path); + nominal_glyph_run_free(&run); + return artifact; + } + + free[u8](&artifact.text); + artifact.text = text_copy_bytes(spec.text); + artifact.type_token_id = semantic_type_name(spec.type_token); + artifact.color_token_id = semantic_color(spec.color_token).id; + artifact.resource_id = canvas_resource_id( + artifact.canvas.key, text_local_path_resource_id()); + artifact.evidence_hash = text_artifact_hash(artifact); + path_encode_free(&payload); + glyph_run_path_result_free(&run_path); + nominal_glyph_run_free(&run); + return artifact; +} + +fn text_artifact_valid(artifact: *TextArtifact) i32 { + if (artifact.*.error != TextError.none || artifact.*.evidence_hash == 0 || + artifact.*.evidence_hash != text_artifact_hash(artifact.*) || + artifact.*.text.len <= 0 || artifact.*.glyph_count <= 0 || + artifact.*.path_count != 1 || artifact.*.path_command_count <= 0 || + artifact.*.resource_id <= 0 || + artifact.*.canvas.display.ops.len != 1 || + artifact.*.canvas.display.resources.resources.len != 1 || + artifact.*.canvas.has_clip != 1 || + artifact.*.canvas.clip.x != artifact.*.bounds.x || + artifact.*.canvas.clip.y != artifact.*.bounds.y || + artifact.*.canvas.clip.width != artifact.*.bounds.width || + artifact.*.canvas.clip.height != artifact.*.bounds.height || + canvas_verify(&artifact.*.canvas) != CanvasError.none) { return 0; } + let op: DisplayOp = artifact.*.canvas.display.ops.data[0]; + let resource: RenderResource = + artifact.*.canvas.display.resources.resources.data[0]; + return (op.kind == DisplayOpKind.draw_path && + op.resource_id == artifact.*.resource_id && + resource.id == artifact.*.resource_id && + resource.kind == RenderResourceKind.path && + resource.format_tag == path_resource_format_tag()) as i32; +} + +fn text_semantic_spec(artifact: TextArtifact) SemanticsSpec { + let spec: SemanticsSpec = semantics_spec( + artifact.canvas.key, artifact.semantic_parent, + SemanticRole.text, artifact.text.data[0..artifact.text.len]); + spec.disabled = artifact.disabled; + spec.text_length = artifact.scalar_count; + spec.has_bounds = 1; + spec.bounds = artifact.bounds; + return spec; +} + +fn text_contribution_result() TextContributionResult { + return TextContributionResult{ + .error = TextContributionError.none, + .canvas_error = CanvasError.none, + .display_error = DisplayError.none, + .semantics_error = SemanticsError.none, + .display_operations_added = 0, + .display_resources_added = 0, + .semantic_nodes_added = 0, + }; +} + +fn text_resource_copy(resource: RenderResource) RenderResource { + return RenderResource{ + .id = resource.id, + .kind = resource.kind, + .payload = render_resource_payload_copy( + resource.payload.data[0..resource.payload.len]), + .revision = resource.revision, + .format_tag = resource.format_tag, + .width = resource.width, + .height = resource.height, + .color_space = resource.color_space, + .content_hash = resource.content_hash, + }; +} + +fn text_stage_display(source: DisplayList) TextDisplayStage { + let staged: DisplayList = display_list_make_with_resource_limits( + source.resources.max_resources, source.resources.max_total_bytes, + source.resources.max_payload_bytes); + let result: TextDisplayStage = TextDisplayStage{ + .display = staged, + .valid = 0, + }; + if (source.sealed != 0 || source.resources.sealed != 0 || + source.last_error != DisplayError.none || + source.resources.last_error != RenderResourceError.none || + render_resource_store_configuration_valid(source.resources) == 0 || + source.stack_depth < 0 || source.layer_depth < 0) { return result; } + let total: i64 = 0; + let resource_index: i32 = 0; + while (resource_index < source.resources.resources.len) { + let resource: RenderResource = + source.resources.resources.data[resource_index]; + if (resource.id <= 0 || resource.payload.len <= 0 || + render_resource_hash(resource) != resource.content_hash || + (resource_index > 0 && source.resources.resources.data[ + resource_index - 1].id >= resource.id)) { return result; } + push[RenderResource](&result.display.resources.resources, + text_resource_copy(resource)); + total = total + resource.payload.len as i64; + resource_index = resource_index + 1; + } + if (total != source.resources.total_bytes || + render_resource_store_hash(source.resources) != + source.resources.content_hash) { return result; } + result.display.resources.total_bytes = source.resources.total_bytes; + result.display.resources.revision = source.resources.revision; + result.display.resources.content_hash = source.resources.content_hash; + result.display.resources.last_error = RenderResourceError.none; + result.display.resources.error_id = 0; + result.display.content_hash = display_mix(17, + result.display.resources.content_hash); + result.display.revision = result.display.resources.revision; + let operation_index: i32 = 0; + while (operation_index < source.ops.len) { + if (display_list_push(&result.display, + source.ops.data[operation_index]) != DisplayError.none) { + return result; + } + operation_index = operation_index + 1; + } + if (result.display.stack_depth != source.stack_depth || + result.display.layer_depth != source.layer_depth || + result.display.revision != source.revision || + result.display.content_hash != source.content_hash || + display_list_calculate_hash(result.display) != source.content_hash) { + return result; + } + result.valid = 1; + return result; +} + +fn text_semantics_preflight(artifact: TextArtifact, + destination: SemanticsTree) TextContributionError { + if (artifact.semantics_mode == TextSemanticsMode.parent_owned) { + return TextContributionError.none; + } + if (semantics_find_index(destination, artifact.canvas.key) >= 0) { + return TextContributionError.duplicate_semantic_id; + } + if (node_key_equal(artifact.semantic_parent, semantic_root_key()) == 0 && + semantics_find_index(destination, artifact.semantic_parent) < 0) { + return TextContributionError.missing_semantic_parent; + } + return TextContributionError.none; +} + +// Atomically contributes the sealed visual resource and, only in own mode, +// one text semantic node. Parent-owned mode is visual-only so a parent such as +// PerformanceChart can retain the same semantic ID without duplication. +fn text_contribute(artifact: *TextArtifact, + display: *DisplayList, + semantics: *SemanticsTree) TextContributionResult { + let result: TextContributionResult = text_contribution_result(); + if (text_artifact_valid(artifact) == 0) { + result.error = TextContributionError.invalid_artifact; + return result; + } + let semantic_preflight: TextContributionError = + text_semantics_preflight(artifact.*, semantics.*); + if (semantic_preflight != TextContributionError.none) { + result.error = semantic_preflight; + return result; + } + let display_preflight: CanvasError = canvas_destination_preflight( + artifact.*.canvas, display.*); + if (display_preflight != CanvasError.none) { + result.error = TextContributionError.invalid_destination; + result.canvas_error = display_preflight; + return result; + } + let staged: TextDisplayStage = text_stage_display(display.*); + if (staged.valid == 0) { + display_list_free(&staged.display); + result.error = TextContributionError.invalid_destination; + return result; + } + let operations_before: i32 = staged.display.ops.len; + let resources_before: i32 = staged.display.resources.resources.len; + // Contribution may update Canvas diagnostic scalars. Keep those writes on + // a shallow staged view; owned display/resource storage remains immutable + // and the reusable Text artifact is unchanged on either outcome. + let staged_canvas: Canvas = artifact.*.canvas; + let canvas_error: CanvasError = canvas_contribute_display( + &staged_canvas, &staged.display); + if (canvas_error != CanvasError.none) { + result.error = TextContributionError.display_error; + result.canvas_error = canvas_error; + result.display_error = staged.display.last_error; + display_list_free(&staged.display); + return result; + } + + let semantics_checkpoint: SemanticsCheckpoint = + semantics_tree_checkpoint(semantics.*); + if (artifact.*.semantics_mode == TextSemanticsMode.own) { + let semantic: SemanticsSpec = text_semantic_spec(artifact.*); + let semantics_error: SemanticsError = semantics_add(semantics, semantic); + if (semantics_error != SemanticsError.none) { + _ = semantics_tree_rollback(semantics, semantics_checkpoint); + display_list_free(&staged.display); + result.error = TextContributionError.semantics_error; + result.semantics_error = semantics_error; + return result; + } + } + let previous: DisplayList = display.*; + display.* = staged.display; + display_list_free(&previous); + result.display_operations_added = display.*.ops.len - operations_before; + result.display_resources_added = + display.*.resources.resources.len - resources_before; + result.semantic_nodes_added = + semantics.*.nodes.len - semantics_checkpoint.length; + return result; +} + +fn text_free(artifact: *TextArtifact) void { + free[u8](&artifact.*.text); + _ = canvas_free(&artifact.*.canvas); + artifact.*.evidence_hash = 0; +} diff --git a/src/text/opentype.zag b/src/text/opentype.zag index 17691b1..f560c20 100644 --- a/src/text/opentype.zag +++ b/src/text/opentype.zag @@ -30,6 +30,9 @@ enum OpenTypeError { struct OpenTypeFace { bytes: ArrayList[u8], units_per_em: i32, + ascender: i32, + descender: i32, + line_gap: i32, glyph_count: i32, cmap_offset: i32, cmap_length: i32, @@ -93,6 +96,9 @@ fn opentype_face_empty() OpenTypeFace { return OpenTypeFace{ .bytes = make[u8](1), .units_per_em = 0, + .ascender = 0, + .descender = 0, + .line_gap = 0, .glyph_count = 0, .cmap_offset = 0, .cmap_length = 0, @@ -235,8 +241,14 @@ fn opentype_parse(data: []u8) OpenTypeFace { opentype_fail(&face, OpenTypeError.invalid_metrics, 12); return face; } face.units_per_em = opentype_u16(bytes, head_offset + 18); + face.ascender = opentype_i16(bytes, hhea_offset + 4); + face.descender = opentype_i16(bytes, hhea_offset + 6); + face.line_gap = opentype_i16(bytes, hhea_offset + 8); face.glyph_count = opentype_u16(bytes, maxp_offset + 4); - if (face.units_per_em < 16 || face.units_per_em > 16384 || face.glyph_count <= 0) { + let line_units: i32 = face.ascender - face.descender + face.line_gap; + if (face.units_per_em < 16 || face.units_per_em > 16384 || + face.glyph_count <= 0 || face.ascender <= face.descender || + line_units <= 0) { opentype_fail(&face, OpenTypeError.invalid_metrics, head_offset + 18); return face; } diff --git a/tests/opentype_contract.zag b/tests/opentype_contract.zag index 1ae7425..b047f56 100644 --- a/tests/opentype_contract.zag +++ b/tests/opentype_contract.zag @@ -19,8 +19,10 @@ fn parse_contract(state: *TestState) void { expect(state, face.error == OpenTypeError.none, "bounded SFNT directory and required tables parse"); expect(state, face.units_per_em == 1000 && face.glyph_count == 10 && - face.cmap_format == 4 && face.horizontal_metric_count == 10, - "font metrics and selected cmap format remain explicit"); + face.ascender == 800 && face.descender == 0 - 200 && + face.line_gap == 200 && face.cmap_format == 4 && + face.horizontal_metric_count == 10, + "horizontal and vertical font metrics and selected cmap remain explicit"); let mapped: GlyphLookup = opentype_glyph_for_scalar(face, 65); let missing: GlyphLookup = opentype_glyph_for_scalar(face, 66); expect(state, mapped.error == OpenTypeError.none && mapped.glyph_id == 3, @@ -109,6 +111,15 @@ fn malformed_contract(state: *TestState) void { "table offsets cannot escape owned font bytes"); opentype_face_free(&bad_bounds); free[u8](&bad_bounds_bytes); + let bad_vertical_bytes: ArrayList[u8] = fixture_font(); + fixture_u16(&bad_vertical_bytes, 244, 65336); + fixture_u16(&bad_vertical_bytes, 246, 800); + let bad_vertical: OpenTypeFace = opentype_parse( + bad_vertical_bytes.data[0..bad_vertical_bytes.len]); + expect(state, bad_vertical.error == OpenTypeError.invalid_metrics, + "inverted hhea ascender and descender metrics fail closed"); + opentype_face_free(&bad_vertical); free[u8](&bad_vertical_bytes); + let duplicate_bytes: ArrayList[u8] = fixture_font(); fixture_tag(&duplicate_bytes, 28, "cmap"); let duplicate: OpenTypeFace = opentype_parse( diff --git a/tests/opentype_fuzz.zag b/tests/opentype_fuzz.zag index 1e72353..124923b 100644 --- a/tests/opentype_fuzz.zag +++ b/tests/opentype_fuzz.zag @@ -44,6 +44,8 @@ fn main() i32 { let face: OpenTypeFace = opentype_parse(bytes.data[0..bytes.len]); if (face.error == OpenTypeError.none) { if (face.units_per_em < 16 || face.units_per_em > 16384 || + face.ascender <= face.descender || + face.ascender - face.descender + face.line_gap <= 0 || face.glyph_count <= 0 || (face.cmap_format != 4 && face.cmap_format != 12) || face.cmap_offset < 0 || face.cmap_length <= 0 || diff --git a/tests/support/opentype_fixture.zag b/tests/support/opentype_fixture.zag index 3ac1bcb..be8b076 100644 --- a/tests/support/opentype_fixture.zag +++ b/tests/support/opentype_fixture.zag @@ -38,6 +38,8 @@ fn fixture_font() ArrayList[u8] { fixture_u16(&bytes, 168, 0); fixture_u16(&bytes, 170, 0); fixture_u16(&bytes, 194, 1000); fixture_u16(&bytes, 226, 0); fixture_u32(&bytes, 232, 65536); fixture_u16(&bytes, 236, 10); + fixture_u16(&bytes, 244, 800); fixture_u16(&bytes, 246, 65336); + fixture_u16(&bytes, 248, 200); fixture_u16(&bytes, 274, 10); let metric: i32 = 0; while (metric < 10) { diff --git a/tests/text_contract.zag b/tests/text_contract.zag new file mode 100644 index 0000000..02c2fb8 --- /dev/null +++ b/tests/text_contract.zag @@ -0,0 +1,451 @@ +@import("../src/components/text.zag") +@import("../src/render/cpu_raster.zag") +@import("../src/automation/talkback.zag") +@import("support/opentype_fixture.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn text_test_slice_equal(actual: []u8, expected: []u8) i32 { + if (actual.len != expected.len) { return 0; } + let i: i32 = 0; + while (i < actual.len) { + if (actual[i] != expected[i]) { return 0; } + i = i + 1; + } + return 1; +} + +fn text_test_paint_equal(actual: Paint, expected: Paint) i32 { + return (actual.red == expected.red && actual.green == expected.green && + actual.blue == expected.blue && actual.alpha == expected.alpha && + actual.stroke_width == expected.stroke_width) as i32; +} + +fn text_test_column_has_alpha(surface: CpuSurface, x: i32) i32 { + let y: i32 = 0; + while (y < surface.height) { + let alpha_index: i32 = (y * surface.width + x) * 4 + 3; + if (surface.pixels.data[alpha_index] != 0) { return 1; } + y = y + 1; + } + return 0; +} + +fn text_test_background(display: *DisplayList, id: i64) void { + let background: DisplayOp = display_op(DisplayOpKind.fill_rect, node_key(id)); + background.bounds = rect(0, 0, 64 * unit_scale(), 32 * unit_scale()); + background.paint = semantic_color_paint(SemanticColorToken.canvas); + _ = display_list_push(display, background); +} + +fn metrics_and_resource_contract(state: *TestState, face: OpenTypeFace) void { + let bounds: Rect = rect(2 * unit_scale(), unit_scale(), + 30 * unit_scale(), 20 * unit_scale()); + let spec: TextSpec = text_spec(node_key_scoped(100, 4), + semantic_root_key(), bounds, "AAA", SemanticTypeToken.caption, + SemanticColorToken.text_primary); + let artifact: TextArtifact = text_build(face, spec); + let expected_size: i64 = semantic_type_size(SemanticTypeToken.caption); + let expected_ascender: i64 = ((face.ascender as i64) * expected_size) / + (face.units_per_em as i64); + let expected_descender: i64 = ((face.descender as i64) * expected_size) / + (face.units_per_em as i64); + let expected_gap: i64 = ((face.line_gap as i64) * expected_size) / + (face.units_per_em as i64); + let expected_height: i64 = expected_ascender - expected_descender + + expected_gap; + let expected_baseline: i64 = bounds.y + + (bounds.height - expected_height) / 2 + expected_gap / 2 + + expected_ascender; + let expected_advance: i64 = (1590 * expected_size) / 1000; + expect(state, artifact.error == TextError.none && + artifact.canvas.key.value == 100 && artifact.canvas.key.generation == 4 && + artifact.bounds.x == bounds.x && artifact.bounds.width == bounds.width, + "Text retains one stable scoped NodeKey and the caller's exact bounds"); + expect(state, artifact.font_size == expected_size && + artifact.ascender == expected_ascender && + artifact.descender == expected_descender && + artifact.line_gap == expected_gap && + artifact.line_height == expected_height && + artifact.baseline == expected_baseline, + "baseline and line box derive deterministically from OpenType hhea metrics"); + expect(state, artifact.advance == expected_advance && + artifact.scalar_count == 3 && artifact.glyph_count == 3 && + artifact.path_count == 1 && artifact.path_command_count == 12 && + artifact.clipped == 0, + "artifact evidence exposes advance scalar glyph path and clipping truth"); + expect(state, text_test_slice_equal(artifact.type_token_id, "type.caption") != 0 && + text_test_slice_equal(artifact.color_token_id, "color.text.primary") != 0 && + artifact.type_token == SemanticTypeToken.caption && + artifact.color_token == SemanticColorToken.text_primary, + "semantic type and color token IDs remain explicit provenance"); + expect(state, artifact.canvas.has_clip == 1 && + artifact.canvas.clip.x == bounds.x && artifact.canvas.clip.y == bounds.y && + artifact.canvas.clip.width == bounds.width && + artifact.canvas.clip.height == bounds.height, + "the immutable Canvas records the exact Text clip bounds"); + expect(state, artifact.canvas.display.resources.resources.len == 1 && + artifact.canvas.display.ops.len == 1 && + artifact.canvas.display.ops.data[0].kind == DisplayOpKind.draw_path && + artifact.canvas.display.resources.resources.data[0].kind == + RenderResourceKind.path && + artifact.canvas.display.ops.data[0].resource_id == artifact.resource_id && + text_test_paint_equal(artifact.canvas.display.ops.data[0].paint, + semantic_color_paint(SemanticColorToken.text_primary)) != 0, + "one combined owned path resource renders with the resolved color token"); + let decoded: PathDecodeResult = path_decode( + artifact.canvas.display.resources.resources.data[0].payload.data[ + 0..artifact.canvas.display.resources.resources.data[0].payload.len]); + expect(state, decoded.error == PathError.none && + decoded.path.commands.len == artifact.path_command_count && + decoded.path.sealed == 1, + "the combined glyph-run path payload is canonical and immutable"); + path_free(&decoded.path); + expect(state, artifact.evidence_hash != 0 && text_artifact_valid(&artifact) != 0, + "sealed Text content has a deterministic self-verifying evidence hash"); + text_free(&artifact); +} + +fn validation_contract(state: *TestState, face: OpenTypeFace) void { + let empty_spec: TextSpec = text_spec(node_key(110), semantic_root_key(), + rect(0, 0, 20 * unit_scale(), 20 * unit_scale()), "", + SemanticTypeToken.body, SemanticColorToken.text_primary); + let empty: TextArtifact = text_build(face, empty_spec); + expect(state, empty.error == TextError.invalid_text && empty.evidence_hash == 0, + "empty text fails before a render resource is created"); + text_free(&empty); + + let malformed_spec: TextSpec = text_spec(node_key(111), semantic_root_key(), + rect(0, 0, 20 * unit_scale(), 20 * unit_scale()), "\xC0\xAF", + SemanticTypeToken.body, SemanticColorToken.text_primary); + let malformed: TextArtifact = text_build(face, malformed_spec); + expect(state, malformed.error == TextError.invalid_text && + malformed.canvas.display.resources.resources.len == 0, + "malformed UTF-8 fails strictly and cannot become replacement glyphs"); + text_free(&malformed); + + let missing_spec: TextSpec = text_spec(node_key(112), semantic_root_key(), + rect(0, 0, 20 * unit_scale(), 20 * unit_scale()), "B", + SemanticTypeToken.body, SemanticColorToken.text_primary); + let missing: TextArtifact = text_build(face, missing_spec); + expect(state, missing.error == TextError.glyph_run_error && + missing.glyph_error == GlyphRunError.missing_glyph && + missing.canvas.display.resources.resources.len == 0, + "missing font coverage fails closed without a fabricated path"); + text_free(&missing); + + let invalid_spec: TextSpec = text_spec(node_key(0), semantic_root_key(), + rect(0, 0, 0, 20 * unit_scale()), "A", + SemanticTypeToken.body, SemanticColorToken.text_primary); + let invalid: TextArtifact = text_build(face, invalid_spec); + expect(state, invalid.error == TextError.invalid_spec && + invalid.evidence_hash == 0, + "invalid identity or non-positive exact bounds fail before composition"); + text_free(&invalid); +} + +fn alignment_and_overflow_contract(state: *TestState, + face: OpenTypeFace) void { + let bounds: Rect = rect(5 * unit_scale(), 3 * unit_scale(), + 40 * unit_scale(), 24 * unit_scale()); + let start_spec: TextSpec = text_spec(node_key(120), semantic_root_key(), + bounds, "AAA", SemanticTypeToken.caption, + SemanticColorToken.text_secondary); + let center_spec: TextSpec = start_spec; + center_spec.id = node_key(121); center_spec.alignment = TextAlignment.center; + let end_spec: TextSpec = start_spec; + end_spec.id = node_key(122); end_spec.alignment = TextAlignment.end; + let start: TextArtifact = text_build(face, start_spec); + let center: TextArtifact = text_build(face, center_spec); + let end: TextArtifact = text_build(face, end_spec); + expect(state, start.error == TextError.none && center.error == TextError.none && + end.error == TextError.none && start.origin_x == bounds.x && + center.origin_x == bounds.x + (bounds.width - center.advance) / 2 && + end.origin_x == bounds.x + bounds.width - end.advance, + "start center and end alignment use the same exact measured advance"); + expect(state, start.advance == center.advance && center.advance == end.advance && + start.baseline == center.baseline && center.baseline == end.baseline, + "alignment changes placement without changing metrics or baseline"); + + let title_spec: TextSpec = text_spec(node_key(123), semantic_root_key(), + rect(0, 0, 80 * unit_scale(), 48 * unit_scale()), "A", + SemanticTypeToken.title, SemanticColorToken.text_primary); + let title: TextArtifact = text_build(face, title_spec); + expect(state, title.error == TextError.none && + title.font_size == semantic_type_size(SemanticTypeToken.title) && + title.font_size > start.font_size && title.line_height > start.line_height && + text_test_slice_equal(title.type_token_id, "type.title") != 0, + "semantic type hierarchy controls font size and vertical metrics"); + + let reject_spec: TextSpec = text_spec(node_key(124), semantic_root_key(), + rect(0, 0, 10 * unit_scale(), 20 * unit_scale()), "AAA", + SemanticTypeToken.caption, SemanticColorToken.text_primary); + let rejected: TextArtifact = text_build(face, reject_spec); + expect(state, rejected.error == TextError.overflow && + rejected.canvas.display.resources.resources.len == 0, + "reject overflow returns an explicit error before resource publication"); + let clip_spec: TextSpec = reject_spec; + clip_spec.id = node_key(125); clip_spec.overflow = TextOverflowPolicy.clip; + let clipped: TextArtifact = text_build(face, clip_spec); + expect(state, clipped.error == TextError.none && clipped.clipped == 1 && + clipped.canvas.clip.x == clip_spec.bounds.x && + clipped.canvas.clip.width == clip_spec.bounds.width, + "clip overflow succeeds while retaining explicit clipped truth and exact clip"); + let clip_display: DisplayList = display_list_make(); + let clip_semantics: SemanticsTree = semantics_tree_make(); + let clip_contribution: TextContributionResult = text_contribute( + &clipped, &clip_display, &clip_semantics); + _ = display_list_seal(&clip_display); + let clip_raster: CpuRasterResult = cpu_rasterize(clip_display, 24, 20); + expect(state, clip_contribution.error == TextContributionError.none && + clip_raster.error == CpuRasterError.none && + clip_raster.pixels_touched > 0 && + text_test_column_has_alpha(clip_raster.surface, 13) == 0, + "CPU output cannot escape the exact clip when overflow is accepted"); + + cpu_raster_free(&clip_raster); semantics_tree_free(&clip_semantics); + display_list_free(&clip_display); + text_free(&clipped); text_free(&rejected); text_free(&title); + text_free(&end); text_free(¢er); text_free(&start); +} + +fn contribution_and_talkback_contract(state: *TestState, + face: OpenTypeFace) void { + let bounds: Rect = rect(2 * unit_scale(), 2 * unit_scale(), + 30 * unit_scale(), 20 * unit_scale()); + let spec: TextSpec = text_spec(node_key_scoped(200, 7), node_key(199), + bounds, "AAA", SemanticTypeToken.caption, + SemanticColorToken.accent); + let first: TextArtifact = text_build(face, spec); + let second: TextArtifact = text_build(face, spec); + expect(state, first.error == TextError.none && second.error == TextError.none && + first.evidence_hash == second.evidence_hash && + first.canvas.content_hash == second.canvas.content_hash, + "identical Text input produces identical immutable artifact identity"); + + let first_display: DisplayList = display_list_make(); + let first_semantics: SemanticsTree = semantics_tree_make(); + let parent: SemanticsSpec = semantics_spec(node_key(199), + semantic_root_key(), SemanticRole.group, "Typography region"); + parent.has_bounds = 1; + parent.bounds = rect(0, 0, 64 * unit_scale(), 32 * unit_scale()); + _ = semantics_add(&first_semantics, parent); + let contributed: TextContributionResult = text_contribute( + &first, &first_display, &first_semantics); + let semantic_index: i32 = semantics_find_index(first_semantics, spec.id); + expect(state, contributed.error == TextContributionError.none && + contributed.display_operations_added == 5 && + contributed.display_resources_added == 1 && + contributed.semantic_nodes_added == 1 && semantic_index >= 0, + "Text atomically contributes one clipped Canvas envelope and optional semantics"); + let semantic: SemanticsNode = first_semantics.nodes.data[semantic_index]; + expect(state, semantic.role == SemanticRole.text && + node_key_equal(semantic.id, spec.id) != 0 && + node_key_equal(semantic.parent, spec.semantic_parent) != 0 && + semantic_text_equal(semantic.name, "AAA") != 0 && + semantic.text_length == 3 && semantic.has_bounds == 1 && + semantic.bounds.x == bounds.x && semantic.bounds.y == bounds.y && + semantic.bounds.width == bounds.width && + semantic.bounds.height == bounds.height, + "owned text semantics preserve stable ID strict text length and exact bounds"); + expect(state, first_display.ops.data[0].kind == DisplayOpKind.save && + first_display.ops.data[2].kind == DisplayOpKind.clip_rect && + first_display.ops.data[2].bounds.x == bounds.x && + first_display.ops.data[2].bounds.width == bounds.width && + first_display.ops.data[3].kind == DisplayOpKind.draw_path && + first_display.ops.data[4].kind == DisplayOpKind.restore, + "caller display receives exact save transform clip path restore order"); + + let session: TalkbackSession = talkback_session_make(talkback_capabilities()); + let query: TalkbackResponse = talkback_dispatch(&session, first_semantics, + rect(0, 0, 64 * unit_scale(), 32 * unit_scale()), + talkback_request(1, TalkbackCommand.query, spec.id, + first_semantics.revision)); + expect(state, query.status == TalkbackStatus.accepted && + node_key_equal(query.target_id, spec.id) != 0 && + query.resolved_role == SemanticRole.text && query.has_bounds == 1 && + query.bounds.x == bounds.x && query.bounds.width == bounds.width, + "Zagkit Talkback resolves the same stable Text semantic ID and bounds"); + talkback_session_free(&session); + + let second_display: DisplayList = display_list_make(); + let second_semantics: SemanticsTree = semantics_tree_make(); + _ = semantics_add(&second_semantics, parent); + let second_contribution: TextContributionResult = text_contribute( + &second, &second_display, &second_semantics); + _ = display_list_seal(&first_display); + _ = display_list_seal(&second_display); + let first_raster: CpuRasterResult = cpu_rasterize(first_display, 64, 32); + let second_raster: CpuRasterResult = cpu_rasterize(second_display, 64, 32); + expect(state, second_contribution.error == TextContributionError.none && + first_raster.error == CpuRasterError.none && + second_raster.error == CpuRasterError.none && + first_raster.pixels_touched > 0 && + cpu_surface_hash(first_raster.surface) == + cpu_surface_hash(second_raster.surface), + "two equivalent Text contributions produce deterministic nonempty CPU pixels"); + + cpu_raster_free(&second_raster); cpu_raster_free(&first_raster); + semantics_tree_free(&second_semantics); display_list_free(&second_display); + semantics_tree_free(&first_semantics); display_list_free(&first_display); + text_free(&second); text_free(&first); +} + +fn parent_owned_contract(state: *TestState, face: OpenTypeFace) void { + let spec: TextSpec = text_spec(node_key(300), node_key(299), + rect(0, 0, 30 * unit_scale(), 20 * unit_scale()), "AAA", + SemanticTypeToken.label, SemanticColorToken.chart_axis); + spec.semantics_mode = TextSemanticsMode.parent_owned; + let artifact: TextArtifact = text_build(face, spec); + let display: DisplayList = display_list_make(); + let semantics: SemanticsTree = semantics_tree_make(); + let parent: SemanticsSpec = semantics_spec(node_key(299), + semantic_root_key(), SemanticRole.group, "Chart"); + _ = semantics_add(&semantics, parent); + let existing: SemanticsSpec = semantics_spec(spec.id, node_key(299), + SemanticRole.text, "AAA"); + existing.has_bounds = 1; existing.bounds = spec.bounds; + _ = semantics_add(&semantics, existing); + let semantic_count: i32 = semantics.nodes.len; + let result: TextContributionResult = text_contribute( + &artifact, &display, &semantics); + expect(state, result.error == TextContributionError.none && + result.semantic_nodes_added == 0 && semantics.nodes.len == semantic_count && + display.ops.len == 5 && display.resources.resources.len == 1, + "parent-owned mode renders an existing chart text ID without duplicating semantics"); + display_list_free(&display); semantics_tree_free(&semantics); + text_free(&artifact); +} + +fn atomic_failure_contract(state: *TestState, face: OpenTypeFace) void { + let spec: TextSpec = text_spec(node_key(400), node_key(399), + rect(0, 0, 30 * unit_scale(), 20 * unit_scale()), "AAA", + SemanticTypeToken.caption, SemanticColorToken.text_disabled); + spec.disabled = 1; + let artifact: TextArtifact = text_build(face, spec); + + let duplicate_display: DisplayList = display_list_make(); + text_test_background(&duplicate_display, 900); + let duplicate_semantics: SemanticsTree = semantics_tree_make(); + let parent: SemanticsSpec = semantics_spec(node_key(399), + semantic_root_key(), SemanticRole.group, "Disabled region"); + _ = semantics_add(&duplicate_semantics, parent); + let duplicate: SemanticsSpec = semantics_spec(spec.id, node_key(399), + SemanticRole.text, "Existing disabled text"); + duplicate.disabled = 1; duplicate.has_bounds = 1; + duplicate.bounds = spec.bounds; + _ = semantics_add(&duplicate_semantics, duplicate); + let duplicate_ops: i32 = duplicate_display.ops.len; + let duplicate_resources: i32 = duplicate_display.resources.resources.len; + let duplicate_hash: i64 = duplicate_display.content_hash; + let duplicate_nodes: i32 = duplicate_semantics.nodes.len; + let duplicate_revision: i64 = duplicate_semantics.revision; + let duplicate_result: TextContributionResult = text_contribute( + &artifact, &duplicate_display, &duplicate_semantics); + expect(state, + duplicate_result.error == TextContributionError.duplicate_semantic_id && + duplicate_display.ops.len == duplicate_ops && + duplicate_display.resources.resources.len == duplicate_resources && + duplicate_display.content_hash == duplicate_hash && + duplicate_semantics.nodes.len == duplicate_nodes && + duplicate_semantics.revision == duplicate_revision, + "disabled duplicate semantic IDs fail atomically before display mutation"); + semantics_tree_free(&duplicate_semantics); + display_list_free(&duplicate_display); + + let missing_display: DisplayList = display_list_make(); + text_test_background(&missing_display, 901); + let missing_semantics: SemanticsTree = semantics_tree_make(); + let missing_ops: i32 = missing_display.ops.len; + let missing_hash: i64 = missing_display.content_hash; + let missing_result: TextContributionResult = text_contribute( + &artifact, &missing_display, &missing_semantics); + expect(state, + missing_result.error == TextContributionError.missing_semantic_parent && + missing_display.ops.len == missing_ops && + missing_display.resources.resources.len == 0 && + missing_display.content_hash == missing_hash && + missing_semantics.nodes.len == 0 && missing_semantics.revision == 0, + "disabled missing-parent semantics also fail with every destination unchanged"); + semantics_tree_free(&missing_semantics); display_list_free(&missing_display); + + let collision_display: DisplayList = display_list_make(); + let owned_resource: RenderResource = + artifact.canvas.display.resources.resources.data[0]; + let collision_spec: RenderResourceSpec = render_resource_spec( + owned_resource.id, owned_resource.kind, + owned_resource.payload.data[0..owned_resource.payload.len], + owned_resource.format_tag); + _ = display_list_add_resource(&collision_display, collision_spec); + let collision_semantics: SemanticsTree = semantics_tree_make(); + _ = semantics_add(&collision_semantics, parent); + let collision_hash: i64 = collision_display.content_hash; + let collision_nodes: i32 = collision_semantics.nodes.len; + let collision_result: TextContributionResult = text_contribute( + &artifact, &collision_display, &collision_semantics); + expect(state, collision_result.error == + TextContributionError.invalid_destination && + collision_result.canvas_error == CanvasError.resource_collision && + collision_display.content_hash == collision_hash && + collision_display.resources.resources.len == 1 && + collision_semantics.nodes.len == collision_nodes, + "path resource collisions fail before either caller-owned tree changes"); + semantics_tree_free(&collision_semantics); + display_list_free(&collision_display); + text_free(&artifact); +} + +fn cleanup_contract(state: *TestState, face: OpenTypeFace) void { + let completed: i32 = 0; + let i: i32 = 0; + while (i < 64) { + let spec: TextSpec = text_spec(node_key_scoped( + 500 + i as i64, i as i64), + semantic_root_key(), + rect(0, 0, 30 * unit_scale(), 20 * unit_scale()), "AAA", + SemanticTypeToken.caption, SemanticColorToken.text_primary); + let artifact: TextArtifact = text_build(face, spec); + if (artifact.error == TextError.none && artifact.evidence_hash != 0) { + completed = completed + 1; + } + text_free(&artifact); + if (artifact.canvas.released == 0 || artifact.evidence_hash != 0) { + completed = 0 - 1000; + } + i = i + 1; + } + expect(state, completed == 64, + "repeated Text build and cleanup releases every owned Canvas artifact"); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + let bytes: ArrayList[u8] = fixture_font(); + let face: OpenTypeFace = opentype_parse(bytes.data[0..bytes.len]); + expect(&state, face.error == OpenTypeError.none && face.ascender == 800 && + face.descender == 0 - 200 && face.line_gap == 200, + "synthetic first-party OpenType fixture exposes vertical metrics"); + metrics_and_resource_contract(&state, face); + validation_contract(&state, face); + alignment_and_overflow_contract(&state, face); + contribution_and_talkback_contract(&state, face); + parent_owned_contract(&state, face); + atomic_failure_contract(&state, face); + cleanup_contract(&state, face); + opentype_face_free(&face); free[u8](&bytes); + _zag_print("Text contract: pass="); _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); + _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-text.sh b/tools/test-text.sh new file mode 100755 index 0000000..d3bf21e --- /dev/null +++ b/tools/test-text.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +znc=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} + +if [ ! -x "$znc" ]; then + printf 'text contract: FAIL: Zag compiler not executable: %s\n' "$znc" >&2 + exit 2 +fi + +binary=$(mktemp /tmp/zagkit-test-text.XXXXXX) +cleanup() { rm -f -- "$binary"; } +trap cleanup EXIT + +"$znc" "$root/tests/text_contract.zag" --no-zagd \ + --analyze-strict --no-foreground-cache -o "$binary" +"$binary" From 944a132314313bb03d428c8a3800de91922c2fa3 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 07:02:44 -0700 Subject: [PATCH 095/103] Host performance charts atomically --- docs/components/performance-chart.md | 49 +- docs/evidence/performance-chart-2026-08-09.md | 7 + src/components/performance_chart.zag | 417 ++++++++++++++++++ tests/performance_chart_host_contract.zag | 352 +++++++++++++++ tools/test-performance-chart.sh | 6 +- 5 files changed, 827 insertions(+), 4 deletions(-) create mode 100644 tests/performance_chart_host_contract.zag diff --git a/docs/components/performance-chart.md b/docs/components/performance-chart.md index 5382278..5a0a8f9 100644 --- a/docs/components/performance-chart.md +++ b/docs/components/performance-chart.md @@ -83,6 +83,46 @@ focus ring—not evidence that a native integration rendered the Text children. The component remains experimental until a Text-composed showcase and native accessibility execution cover this boundary. +## Hosted composition + +`performance_chart_contribute_hosted` atomically contributes an already-built +`PerformanceChartArtifact` into caller-owned `DisplayList`, `SemanticsTree`, and +`HitTree` destinations. `PerformanceChartHostSpec` requires three explicit +values: + +- `semantic_parent`, which must be the semantic root or an ID already present in + the destination semantic tree; +- `hit_parent`, which must be the hit root or an ID already present in the + destination hit tree; and +- `focus_order_offset`, a non-negative integer whose shifted chart focus orders + must be no greater than one million and must not collide with any destination + focus order. + +The chart's Canvas group is the only semantic and hit node reparented. Title, +axis, tick, reference, legend, series, row, cell, and table IDs stay unchanged, +and every child retains its existing chart-local parent. Non-zero legend focus +orders become `source order + focus_order_offset`; zero remains zero. The API +never searches for an unused focus range or silently renumbers the host. + +The caller must provide an unsealed, internally consistent display list with +enough operation/resource capacity and no resource IDs used by the chart. All +chart semantic and hit IDs must also be absent. Missing parents, duplicate IDs, +resource collisions, invalid offsets, and focus collisions fail closed. + +Display contribution is staged in a deep owned clone. The caller's original +display list is replaced only after Canvas, semantics, and hit contribution all +succeed. Semantic mutation uses `SemanticsCheckpoint`; hit mutation uses an +exact length/revision/error checkpoint. Any contribution error restores the +semantic and hit destinations and discards the staged display, so a result with +an error never represents partial chart attachment. + +Successful hosted contribution still does not render typography. Before sealing +the destination display list, the host must render every owned `text_slots` +entry through Zagkit Text using its exact ID, bounds, type token, color token, +and text. The host owns any wider transaction that combines chart attachment +with those subsequent Text children. It must keep the artifact alive while +reading slots and call `performance_chart_free` when finished. + ## Semantics and data equivalence The Canvas contributes one named group. Its children provide: @@ -120,10 +160,13 @@ Run: ./tools/test-performance-chart.sh ``` -The contract builds the same chart twice, verifies sealed Canvas identity, -Flex-derived geometry, token roles, complete text slots, semantic table +The root-owned contract builds the same chart twice, verifies sealed Canvas +identity, Flex-derived geometry, token roles, complete text slots, semantic table coordinates, stable-ID Talkback actions, fail-closed duplicate IDs, and -pixel-identical CPU output. +pixel-identical CPU output. The hosted contract additionally verifies explicit +parents, exact contribution counts, root-only reparenting, stable child IDs, +focus-offset collision policy, atomic duplicate/missing-parent failures, and +deterministic caller-owned display content. This slice does not provide arbitrary chart types, logarithmic/time scales, locale formatting, axis collision avoidance, zoom, pan, tooltip interaction, diff --git a/docs/evidence/performance-chart-2026-08-09.md b/docs/evidence/performance-chart-2026-08-09.md index 02e21b3..e18c33a 100644 --- a/docs/evidence/performance-chart-2026-08-09.md +++ b/docs/evidence/performance-chart-2026-08-09.md @@ -7,6 +7,8 @@ production polish. ## Focused result - Strict executable contract: `Performance chart contract: pass=18 fail=0`. +- Strict hosted-composition contract: + `Performance chart host contract: pass=9 fail=0`. - Retained evidence hash: `1042463816`. - Deterministic Canvas-only CPU surface hash: `1813314353`. - The same spec rebuilt with identical display-list, semantic, typography-slot, @@ -29,6 +31,11 @@ Twenty-four owned typography slots cover the title, four axis name/unit roles, fourteen tick labels, three legend names, baseline, and deadline. Each slot records exact bounds, stable semantic ID, type token, and color token. +The hosted contract contributes the same built artifact beneath explicit caller +semantic and hit parents. It preserves all chart-local IDs and child parentage, +offsets focus order through a caller-selected collision-checked range, stages +display ownership, and fails atomically on duplicate IDs or missing parents. + ## Honest boundary The CPU hash covers Canvas-owned visual geometry only. The current component diff --git a/src/components/performance_chart.zag b/src/components/performance_chart.zag index fb43a44..d696f5c 100644 --- a/src/components/performance_chart.zag +++ b/src/components/performance_chart.zag @@ -122,6 +122,75 @@ struct PerformanceChartArtifact { evidence_hash: i64, } +enum PerformanceChartHostError { + none, + invalid_artifact, + invalid_destination, + invalid_focus_offset, + missing_semantic_parent, + missing_hit_parent, + duplicate_semantic_id, + duplicate_hit_id, + focus_order_collision, + display_error, + semantics_error, + hit_error, +} + +struct PerformanceChartHostSpec { + semantic_parent: NodeKey, + hit_parent: NodeKey, + focus_order_offset: i64, +} + +struct PerformanceChartHostResult { + error: PerformanceChartHostError, + canvas_error: CanvasError, + display_error: DisplayError, + semantics_error: SemanticsError, + hit_error: HitTreeError, + display_operations_added: i32, + display_resources_added: i32, + semantic_nodes_added: i32, + hit_nodes_added: i32, +} + +struct PerformanceChartHostCheckpoint { + semantics: SemanticsCheckpoint, + hit_length: i32, + hit_revision: i64, + hit_last_error: HitTreeError, + hit_error_node: NodeKey, +} + +struct PerformanceChartDisplayStage { + display: DisplayList, + valid: i32, +} + +fn performance_chart_host_spec(semantic_parent: NodeKey, + hit_parent: NodeKey, focus_order_offset: i64) PerformanceChartHostSpec { + return PerformanceChartHostSpec{ + .semantic_parent = semantic_parent, + .hit_parent = hit_parent, + .focus_order_offset = focus_order_offset, + }; +} + +fn performance_chart_host_empty_result() PerformanceChartHostResult { + return PerformanceChartHostResult{ + .error = PerformanceChartHostError.none, + .canvas_error = CanvasError.none, + .display_error = DisplayError.none, + .semantics_error = SemanticsError.none, + .hit_error = HitTreeError.none, + .display_operations_added = 0, + .display_resources_added = 0, + .semantic_nodes_added = 0, + .hit_nodes_added = 0, + }; +} + fn performance_chart_point(x: i64, y: i64, x_value: []u8, y_value: []u8) PerformanceChartPoint { return PerformanceChartPoint{ @@ -1049,3 +1118,351 @@ fn performance_chart_build(spec: PerformanceChartSpec) PerformanceChartArtifact artifact.evidence_hash = performance_chart_hash(artifact); return artifact; } + +fn performance_chart_host_focus_order_limit() i64 { return 1000000; } + +fn performance_chart_host_checkpoint(semantics: SemanticsTree, + hits: HitTree) PerformanceChartHostCheckpoint { + return PerformanceChartHostCheckpoint{ + .semantics = semantics_tree_checkpoint(semantics), + .hit_length = hits.nodes.len, + .hit_revision = hits.revision, + .hit_last_error = hits.last_error, + .hit_error_node = hits.error_node, + }; +} + +fn performance_chart_host_rollback(semantics: *SemanticsTree, + hits: *HitTree, + checkpoint: PerformanceChartHostCheckpoint) void { + _ = semantics_tree_rollback(semantics, checkpoint.semantics); + hits.*.nodes.len = checkpoint.hit_length; + hits.*.revision = checkpoint.hit_revision; + hits.*.last_error = checkpoint.hit_last_error; + hits.*.error_node = checkpoint.hit_error_node; +} + +fn performance_chart_host_semantic_spec(node: SemanticsNode) SemanticsSpec { + return SemanticsSpec{ + .id = node.id, + .parent = node.parent, + .role = node.role, + .name = node.name.data[0..node.name.len], + .description = node.description.data[0..node.description.len], + .value = node.value.data[0..node.value.len], + .action_mask = node.action_mask, + .focus_order = node.focus_order, + .disabled = node.disabled, + .selected = node.selected, + .hidden = node.hidden, + .live_region = node.live_region, + .has_range = node.has_range, + .range_min = node.range_min, + .range_max = node.range_max, + .range_value = node.range_value, + .range_step = node.range_step, + .text_length = node.text_length, + .selection_start = node.selection_start, + .selection_end = node.selection_end, + .has_bounds = node.has_bounds, + .bounds = node.bounds, + .labelled_by = node.labelled_by, + .described_by = node.described_by, + .controls = node.controls, + .row_count = node.row_count, + .column_count = node.column_count, + .row_index = node.row_index, + .column_index = node.column_index, + .row_span = node.row_span, + .column_span = node.column_span, + .level = node.level, + .set_size = node.set_size, + .position_in_set = node.position_in_set, + .expanded = node.expanded, + }; +} + +fn performance_chart_host_artifact_valid( + artifact: PerformanceChartArtifact) i32 { + let semantic_root: i32 = semantics_find_index( + artifact.semantics, artifact.canvas.key); + let hit_root: i32 = hit_tree_find_index( + artifact.hits, artifact.canvas.key); + if (artifact.error != PerformanceChartError.none || + artifact.evidence_hash == 0 || + artifact.evidence_hash != performance_chart_hash(artifact) || + artifact.semantics.nodes.len <= 0 || artifact.hits.nodes.len <= 0 || + artifact.hits.nodes.len != artifact.legend.len + 1) { return 0; } + if (semantic_root < 0 || hit_root < 0 || + node_key_equal(artifact.semantics.nodes.data[semantic_root].parent, + semantic_root_key()) == 0 || + node_key_equal(artifact.hits.nodes.data[hit_root].parent, + hit_root_key()) == 0) { return 0; } + let i: i32 = 0; + while (i < artifact.legend.len) { + let id: NodeKey = artifact.legend.data[i].id; + let semantic_index: i32 = semantics_find_index( + artifact.semantics, id); + let hit_index: i32 = hit_tree_find_index(artifact.hits, id); + if (semantic_index < 0 || hit_index < 0 || + node_key_equal(artifact.semantics.nodes.data[semantic_index].parent, + performance_chart_legend_id(artifact.canvas.key)) == 0 || + node_key_equal(artifact.hits.nodes.data[hit_index].parent, + artifact.canvas.key) == 0) { return 0; } + i = i + 1; + } + return 1; +} + +fn performance_chart_host_resource_copy(resource: RenderResource) RenderResource { + return RenderResource{ + .id = resource.id, + .kind = resource.kind, + .payload = render_resource_payload_copy( + resource.payload.data[0..resource.payload.len]), + .revision = resource.revision, + .format_tag = resource.format_tag, + .width = resource.width, + .height = resource.height, + .color_space = resource.color_space, + .content_hash = resource.content_hash, + }; +} + +fn performance_chart_host_stage_display(source: DisplayList) + PerformanceChartDisplayStage { + let staged: DisplayList = display_list_make_with_resource_limits( + source.resources.max_resources, source.resources.max_total_bytes, + source.resources.max_payload_bytes); + let result: PerformanceChartDisplayStage = PerformanceChartDisplayStage{ + .display = staged, + .valid = 0, + }; + if (source.sealed != 0 || source.resources.sealed != 0 || + source.last_error != DisplayError.none || + source.resources.last_error != RenderResourceError.none || + source.error_index != 0 - 1 || source.resources.error_id != 0 || + render_resource_store_configuration_valid(source.resources) == 0 || + source.stack_depth < 0 || source.layer_depth < 0) { return result; } + let total: i64 = 0; + let resource_index: i32 = 0; + while (resource_index < source.resources.resources.len) { + let resource: RenderResource = source.resources.resources.data[resource_index]; + if (resource.id <= 0 || resource.payload.len <= 0 || + render_resource_hash(resource) != resource.content_hash || + (resource_index > 0 && source.resources.resources.data[ + resource_index - 1].id >= resource.id)) { return result; } + push[RenderResource](&result.display.resources.resources, + performance_chart_host_resource_copy(resource)); + total = total + resource.payload.len as i64; + resource_index = resource_index + 1; + } + if (total != source.resources.total_bytes || + render_resource_store_hash(source.resources) != + source.resources.content_hash) { return result; } + result.display.resources.total_bytes = source.resources.total_bytes; + result.display.resources.revision = source.resources.revision; + result.display.resources.content_hash = source.resources.content_hash; + result.display.resources.last_error = RenderResourceError.none; + result.display.resources.error_id = 0; + result.display.content_hash = display_mix(17, + result.display.resources.content_hash); + result.display.revision = result.display.resources.revision; + let operation_index: i32 = 0; + while (operation_index < source.ops.len) { + if (display_list_push(&result.display, + source.ops.data[operation_index]) != DisplayError.none) { return result; } + operation_index = operation_index + 1; + } + if (result.display.stack_depth != source.stack_depth || + result.display.layer_depth != source.layer_depth || + result.display.revision != source.revision || + result.display.content_hash != source.content_hash || + display_list_calculate_hash(result.display) != source.content_hash) { + return result; + } + result.valid = 1; + return result; +} + +fn performance_chart_host_semantics_preflight( + artifact: PerformanceChartArtifact, + host: PerformanceChartHostSpec, + destination: SemanticsTree) PerformanceChartHostError { + if (node_key_equal(host.semantic_parent, semantic_root_key()) == 0 && + semantics_find_index(destination, host.semantic_parent) < 0) { + return PerformanceChartHostError.missing_semantic_parent; + } + let i: i32 = 0; + while (i < artifact.semantics.nodes.len) { + let source: SemanticsNode = artifact.semantics.nodes.data[i]; + if (semantics_find_index(destination, source.id) >= 0) { + return PerformanceChartHostError.duplicate_semantic_id; + } + if (source.focus_order > 0) { + if (source.focus_order > performance_chart_host_focus_order_limit() - + host.focus_order_offset) { + return PerformanceChartHostError.invalid_focus_offset; + } + let hosted_order: i64 = source.focus_order + host.focus_order_offset; + let existing: i32 = 0; + while (existing < destination.nodes.len) { + if (destination.nodes.data[existing].focus_order == hosted_order) { + return PerformanceChartHostError.focus_order_collision; + } + existing = existing + 1; + } + } + i = i + 1; + } + return PerformanceChartHostError.none; +} + +fn performance_chart_host_hits_preflight( + artifact: PerformanceChartArtifact, + host: PerformanceChartHostSpec, + destination: HitTree) PerformanceChartHostError { + if (node_key_equal(host.hit_parent, hit_root_key()) == 0 && + hit_tree_find_index(destination, host.hit_parent) < 0) { + return PerformanceChartHostError.missing_hit_parent; + } + let i: i32 = 0; + while (i < artifact.hits.nodes.len) { + if (hit_tree_find_index(destination, + artifact.hits.nodes.data[i].id) >= 0) { + return PerformanceChartHostError.duplicate_hit_id; + } + i = i + 1; + } + return PerformanceChartHostError.none; +} + +fn performance_chart_host_add_semantics( + artifact: PerformanceChartArtifact, + host: PerformanceChartHostSpec, + destination: *SemanticsTree) SemanticsError { + let i: i32 = 0; + while (i < artifact.semantics.nodes.len) { + let source: SemanticsNode = artifact.semantics.nodes.data[i]; + let spec: SemanticsSpec = performance_chart_host_semantic_spec(source); + if (node_key_equal(source.id, artifact.canvas.key) != 0) { + if (node_key_equal(source.parent, semantic_root_key()) == 0) { + return SemanticsError.missing_parent; + } + spec.parent = host.semantic_parent; + } + if (spec.focus_order > 0) { + spec.focus_order = spec.focus_order + host.focus_order_offset; + } + let error: SemanticsError = semantics_add(destination, spec); + if (error != SemanticsError.none) { return error; } + i = i + 1; + } + return SemanticsError.none; +} + +fn performance_chart_host_add_hits( + artifact: PerformanceChartArtifact, + host: PerformanceChartHostSpec, + destination: *HitTree) HitTreeError { + let i: i32 = 0; + while (i < artifact.hits.nodes.len) { + let source: HitNode = artifact.hits.nodes.data[i]; + if (node_key_equal(source.id, artifact.canvas.key) != 0) { + if (node_key_equal(source.parent, hit_root_key()) == 0) { + return HitTreeError.missing_parent; + } + source.parent = host.hit_parent; + } + let error: HitTreeError = hit_tree_add(destination, source); + if (error != HitTreeError.none) { return error; } + i = i + 1; + } + return HitTreeError.none; +} + +// Atomically hosts one already-built chart beneath caller-owned semantic and +// hit parents. Only the chart root is reparented. Child IDs and parent links are +// preserved, while non-zero focus orders are shifted by the explicit offset. +fn performance_chart_contribute_hosted( + artifact: *PerformanceChartArtifact, + host: PerformanceChartHostSpec, + display: *DisplayList, + semantics: *SemanticsTree, + hits: *HitTree) PerformanceChartHostResult { + let result: PerformanceChartHostResult = performance_chart_host_empty_result(); + if (performance_chart_host_artifact_valid(artifact.*) == 0 || + canvas_verify(&artifact.*.canvas) != CanvasError.none) { + result.error = PerformanceChartHostError.invalid_artifact; return result; + } + if (host.focus_order_offset < 0 || host.focus_order_offset > + performance_chart_host_focus_order_limit()) { + result.error = PerformanceChartHostError.invalid_focus_offset; return result; + } + let semantic_preflight: PerformanceChartHostError = + performance_chart_host_semantics_preflight(artifact.*, host, semantics.*); + if (semantic_preflight != PerformanceChartHostError.none) { + result.error = semantic_preflight; return result; + } + let hit_preflight: PerformanceChartHostError = + performance_chart_host_hits_preflight(artifact.*, host, hits.*); + if (hit_preflight != PerformanceChartHostError.none) { + result.error = hit_preflight; return result; + } + let display_preflight: CanvasError = canvas_destination_preflight( + artifact.*.canvas, display.*); + if (display_preflight != CanvasError.none) { + result.error = PerformanceChartHostError.invalid_destination; + result.canvas_error = display_preflight; return result; + } + let staged: PerformanceChartDisplayStage = + performance_chart_host_stage_display(display.*); + if (staged.valid == 0) { + display_list_free(&staged.display); + result.error = PerformanceChartHostError.invalid_destination; + return result; + } + let display_ops_before: i32 = staged.display.ops.len; + let display_resources_before: i32 = staged.display.resources.resources.len; + // Canvas reports contribution failures through scalar diagnostic fields. + // Keep those writes local so a failed hosted transaction cannot invalidate + // the reusable caller-owned artifact. + let staged_canvas: Canvas = artifact.*.canvas; + let contributed: CanvasError = canvas_contribute_display( + &staged_canvas, &staged.display); + if (contributed != CanvasError.none) { + result.error = PerformanceChartHostError.display_error; + result.canvas_error = contributed; + result.display_error = staged.display.last_error; + display_list_free(&staged.display); return result; + } + + let checkpoint: PerformanceChartHostCheckpoint = + performance_chart_host_checkpoint(semantics.*, hits.*); + let semantic_error: SemanticsError = performance_chart_host_add_semantics( + artifact.*, host, semantics); + if (semantic_error != SemanticsError.none) { + performance_chart_host_rollback(semantics, hits, checkpoint); + display_list_free(&staged.display); + result.error = PerformanceChartHostError.semantics_error; + result.semantics_error = semantic_error; return result; + } + let hit_error: HitTreeError = performance_chart_host_add_hits( + artifact.*, host, hits); + if (hit_error != HitTreeError.none) { + performance_chart_host_rollback(semantics, hits, checkpoint); + display_list_free(&staged.display); + result.error = PerformanceChartHostError.hit_error; + result.hit_error = hit_error; return result; + } + + let previous: DisplayList = display.*; + display.* = staged.display; + display_list_free(&previous); + result.display_operations_added = display.*.ops.len - display_ops_before; + result.display_resources_added = display.*.resources.resources.len - + display_resources_before; + result.semantic_nodes_added = semantics.*.nodes.len - checkpoint.semantics.length; + result.hit_nodes_added = hits.*.nodes.len - checkpoint.hit_length; + return result; +} diff --git a/tests/performance_chart_host_contract.zag b/tests/performance_chart_host_contract.zag new file mode 100644 index 0000000..d453deb --- /dev/null +++ b/tests/performance_chart_host_contract.zag @@ -0,0 +1,352 @@ +@import("../src/components/performance_chart.zag") + +struct TestState { passed: i32, failed: i32 } + +struct HostedDestination { + display: DisplayList, + semantics: SemanticsTree, + hits: HitTree, +} + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn host_chart_points(y0: i64, y1: i64, y2: i64, y3: i64, + t0: []u8, t1: []u8, t2: []u8, t3: []u8) + ArrayList[PerformanceChartPoint] { + let points: ArrayList[PerformanceChartPoint] = + make[PerformanceChartPoint](4); + push[PerformanceChartPoint](&points, + performance_chart_point(0, y0, "0 s", t0)); + push[PerformanceChartPoint](&points, + performance_chart_point(10, y1, "10 s", t1)); + push[PerformanceChartPoint](&points, + performance_chart_point(20, y2, "20 s", t2)); + push[PerformanceChartPoint](&points, + performance_chart_point(30, y3, "30 s", t3)); + return points; +} + +fn hosted_destination_init(destination: *HostedDestination, + existing_focus_order: i64) void { + let host_resource: RenderResourceSpec = render_resource_spec(910000, + RenderResourceKind.svg_source, "", 1); + _ = display_list_add_resource(&destination.*.display, host_resource); + let background: DisplayOp = display_op( + DisplayOpKind.fill_rect, node_key(8000)); + background.bounds = rect(0, 0, 960 * unit_scale(), 560 * unit_scale()); + background.paint = semantic_color_paint(SemanticColorToken.canvas); + _ = display_list_push(&destination.display, background); + let semantic_parent: SemanticsSpec = semantics_spec(node_key(8000), + semantic_root_key(), SemanticRole.group, "Hosted chart region"); + semantic_parent.has_bounds = 1; semantic_parent.bounds = background.bounds; + _ = semantics_add(&destination.semantics, semantic_parent); + let hit_parent: HitNode = hit_node(node_key(9000), hit_root_key(), + background.bounds); + _ = hit_tree_add(&destination.hits, hit_parent); + if (existing_focus_order > 0) { + let focused: SemanticsSpec = semantics_spec(node_key(8001), + node_key(8000), SemanticRole.button, "Existing focus target"); + focused.focus_order = existing_focus_order; + focused.action_mask = semantic_actions(SemanticAction.focus); + focused.has_bounds = 1; + focused.bounds = rect(8 * unit_scale(), 8 * unit_scale(), + 80 * unit_scale(), 40 * unit_scale()); + _ = semantics_add(&destination.semantics, focused); + } +} + +fn hosted_destination_free(destination: *HostedDestination) void { + hit_tree_free(&destination.*.hits); + semantics_tree_free(&destination.*.semantics); + display_list_free(&destination.*.display); +} + +fn hosted_semantic_collision_init(destination: *HostedDestination, + collision_id: NodeKey) void { + hosted_destination_init(destination, 0); + let colliding: SemanticsSpec = semantics_spec(collision_id, + node_key(8000), SemanticRole.group, "Collision"); + _ = semantics_add(&destination.*.semantics, colliding); +} + +fn hosted_hit_collision_init(destination: *HostedDestination, + collision_id: NodeKey) void { + hosted_destination_init(destination, 0); + _ = hit_tree_add(&destination.*.hits, + hit_node(collision_id, node_key(9000), + rect(0, 0, 40 * unit_scale(), 40 * unit_scale()))); +} + +fn stable_slot_present(artifact: *PerformanceChartArtifact, id: NodeKey, + kind: PerformanceChartTextSlotKind) i32 { + let i: i32 = 0; + while (i < artifact.*.text_slots.len) { + if (node_key_equal(artifact.*.text_slots.data[i].id, id) != 0 && + artifact.*.text_slots.data[i].kind == kind) { return 1; } + i = i + 1; + } + return 0; +} + +fn hosted_success_contract(state: *TestState, + artifact: *PerformanceChartArtifact) void { + let destination: HostedDestination = HostedDestination{ + .display = display_list_make(), .semantics = semantics_tree_make(), + .hits = hit_tree_make(), + }; + hosted_destination_init(&destination, 0); + let semantic_before: i32 = destination.semantics.nodes.len; + let hit_before: i32 = destination.hits.nodes.len; + let ops_before: i32 = destination.display.ops.len; + let resources_before: i32 = + destination.display.resources.resources.len; + let result: PerformanceChartHostResult = performance_chart_contribute_hosted( + artifact, performance_chart_host_spec(node_key(8000), node_key(9000), 20), + &destination.display, &destination.semantics, &destination.hits); + expect(state, result.error == PerformanceChartHostError.none && + result.semantic_nodes_added == artifact.*.semantics.nodes.len && + result.hit_nodes_added == artifact.*.hits.nodes.len && + destination.semantics.nodes.len == semantic_before + + artifact.*.semantics.nodes.len && + destination.hits.nodes.len == hit_before + artifact.*.hits.nodes.len && + destination.display.ops.len == ops_before + + artifact.*.canvas.display.ops.len + 4 && + result.display_resources_added == + artifact.*.canvas.display.resources.resources.len && + destination.display.resources.resources.len == resources_before + + artifact.*.canvas.display.resources.resources.len && + render_resource_find_index(destination.display.resources, 910000) >= 0, + "host contribution adds exact display semantic and hit counts"); + + let chart_semantic: i32 = semantics_find_index(destination.semantics, + artifact.*.canvas.key); + let chart_hit: i32 = hit_tree_find_index(destination.hits, + artifact.*.canvas.key); + let series_hit: i32 = hit_tree_find_index(destination.hits, node_key(7100)); + let legend: i32 = semantics_find_index(destination.semantics, + performance_chart_legend_id(artifact.*.canvas.key)); + let series: i32 = semantics_find_index(destination.semantics, node_key(7100)); + let table: i32 = semantics_find_index(destination.semantics, + performance_chart_table_id(artifact.*.canvas.key)); + let title: i32 = semantics_find_index(destination.semantics, + performance_chart_title_id(artifact.*.canvas.key)); + expect(state, chart_semantic >= 0 && chart_hit >= 0 && series_hit >= 0 && + legend >= 0 && series >= 0 && table >= 0 && title >= 0 && + node_key_equal(destination.semantics.nodes.data[chart_semantic].parent, + node_key(8000)) != 0 && + node_key_equal(destination.hits.nodes.data[chart_hit].parent, + node_key(9000)) != 0 && + node_key_equal(destination.hits.nodes.data[series_hit].parent, + artifact.*.canvas.key) != 0 && + node_key_equal(destination.semantics.nodes.data[legend].parent, + artifact.*.canvas.key) != 0 && + node_key_equal(destination.semantics.nodes.data[series].parent, + performance_chart_legend_id(artifact.*.canvas.key)) != 0 && + node_key_equal(destination.semantics.nodes.data[table].parent, + artifact.*.canvas.key) != 0 && + node_key_equal(destination.semantics.nodes.data[title].parent, + artifact.*.canvas.key) != 0, + "only the chart roots are reparented beneath explicit host parents"); + expect(state, destination.semantics.nodes.data[series].focus_order == 21 && + destination.semantics.nodes.data[ + semantics_find_index(destination.semantics, node_key(7101)) + ].focus_order == 22, + "host focus offset deterministically shifts non-zero chart orders"); + expect(state, stable_slot_present(artifact, + performance_chart_title_id(artifact.*.canvas.key), + PerformanceChartTextSlotKind.title) != 0 && + stable_slot_present(artifact, node_key(7100), + PerformanceChartTextSlotKind.legend_name) != 0 && + semantics_find_index(destination.semantics, + performance_chart_table_cell_id(artifact.*.canvas.key, 2, 2)) >= 0, + "host contribution preserves chart table legend and text-slot identities"); + + let repeated: HostedDestination = HostedDestination{ + .display = display_list_make(), .semantics = semantics_tree_make(), + .hits = hit_tree_make(), + }; + hosted_destination_init(&repeated, 0); + let repeated_result: PerformanceChartHostResult = + performance_chart_contribute_hosted(artifact, + performance_chart_host_spec(node_key(8000), node_key(9000), 20), + &repeated.display, &repeated.semantics, &repeated.hits); + expect(state, repeated_result.error == PerformanceChartHostError.none && + repeated.display.content_hash == destination.display.content_hash && + repeated.display.ops.len == destination.display.ops.len && + repeated.display.resources.content_hash == + destination.display.resources.content_hash, + "identical hosted input produces deterministic caller-owned display content"); + hosted_destination_free(&repeated); + hosted_destination_free(&destination); +} + +fn hosted_failure_contract(state: *TestState, + artifact: *PerformanceChartArtifact) void { + let duplicate_semantic: HostedDestination = HostedDestination{ + .display = display_list_make(), .semantics = semantics_tree_make(), + .hits = hit_tree_make(), + }; + hosted_semantic_collision_init(&duplicate_semantic, + performance_chart_table_id(artifact.*.canvas.key)); + let semantic_length: i32 = duplicate_semantic.semantics.nodes.len; + let semantic_revision: i64 = duplicate_semantic.semantics.revision; + let hit_length: i32 = duplicate_semantic.hits.nodes.len; + let display_hash: i64 = duplicate_semantic.display.content_hash; + let duplicate_result: PerformanceChartHostResult = + performance_chart_contribute_hosted(artifact, + performance_chart_host_spec(node_key(8000), node_key(9000), 0), + &duplicate_semantic.display, &duplicate_semantic.semantics, + &duplicate_semantic.hits); + expect(state, + duplicate_result.error == PerformanceChartHostError.duplicate_semantic_id && + duplicate_semantic.semantics.nodes.len == semantic_length && + duplicate_semantic.semantics.revision == semantic_revision && + duplicate_semantic.hits.nodes.len == hit_length && + duplicate_semantic.display.content_hash == display_hash, + "duplicate semantic identity fails atomically before host mutation"); + hosted_destination_free(&duplicate_semantic); + + let duplicate_hit: HostedDestination = HostedDestination{ + .display = display_list_make(), .semantics = semantics_tree_make(), + .hits = hit_tree_make(), + }; + hosted_hit_collision_init(&duplicate_hit, node_key(7100)); + let duplicate_hit_length: i32 = duplicate_hit.hits.nodes.len; + let duplicate_hit_revision: i64 = duplicate_hit.hits.revision; + let duplicate_hit_semantics: i32 = duplicate_hit.semantics.nodes.len; + let duplicate_hit_hash: i64 = duplicate_hit.display.content_hash; + let hit_result: PerformanceChartHostResult = + performance_chart_contribute_hosted(artifact, + performance_chart_host_spec(node_key(8000), node_key(9000), 0), + &duplicate_hit.display, &duplicate_hit.semantics, &duplicate_hit.hits); + expect(state, hit_result.error == PerformanceChartHostError.duplicate_hit_id && + duplicate_hit.hits.nodes.len == duplicate_hit_length && + duplicate_hit.hits.revision == duplicate_hit_revision && + duplicate_hit.semantics.nodes.len == duplicate_hit_semantics && + duplicate_hit.display.content_hash == duplicate_hit_hash, + "duplicate hit identity also leaves every destination atomic"); + hosted_destination_free(&duplicate_hit); + + let missing: HostedDestination = HostedDestination{ + .display = display_list_make(), .semantics = semantics_tree_make(), + .hits = hit_tree_make(), + }; + hosted_destination_init(&missing, 0); + let missing_semantic: PerformanceChartHostResult = + performance_chart_contribute_hosted(artifact, + performance_chart_host_spec(node_key(9998), node_key(9000), 0), + &missing.display, &missing.semantics, &missing.hits); + let missing_hit: PerformanceChartHostResult = performance_chart_contribute_hosted( + artifact, performance_chart_host_spec(node_key(8000), node_key(9999), 0), + &missing.display, &missing.semantics, &missing.hits); + expect(state, + missing_semantic.error == + PerformanceChartHostError.missing_semantic_parent && + missing_hit.error == PerformanceChartHostError.missing_hit_parent && + missing.semantics.nodes.len == 1 && missing.hits.nodes.len == 1 && + missing.display.ops.len == 1, + "missing semantic and hit parents fail closed without mutation"); + hosted_destination_free(&missing); + + let focus_collision: HostedDestination = HostedDestination{ + .display = display_list_make(), .semantics = semantics_tree_make(), + .hits = hit_tree_make(), + }; + hosted_destination_init(&focus_collision, 21); + let focus_length: i32 = focus_collision.semantics.nodes.len; + let rejected: PerformanceChartHostResult = performance_chart_contribute_hosted( + artifact, performance_chart_host_spec(node_key(8000), node_key(9000), 20), + &focus_collision.display, &focus_collision.semantics, + &focus_collision.hits); + let accepted: PerformanceChartHostResult = performance_chart_contribute_hosted( + artifact, performance_chart_host_spec(node_key(8000), node_key(9000), 30), + &focus_collision.display, &focus_collision.semantics, + &focus_collision.hits); + let shifted: i32 = semantics_find_index(focus_collision.semantics, + node_key(7100)); + expect(state, + rejected.error == PerformanceChartHostError.focus_order_collision && + accepted.error == PerformanceChartHostError.none && + focus_collision.semantics.nodes.len == focus_length + + artifact.*.semantics.nodes.len && shifted >= 0 && + focus_collision.semantics.nodes.data[shifted].focus_order == 31, + "explicit focus offset rejects collisions and accepts a disjoint range"); + hosted_destination_free(&focus_collision); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + let cpu: ArrayList[PerformanceChartPoint] = host_chart_points( + 10, 11, 12, 10, "10 ms", "11 ms", "12 ms", "10 ms"); + let gpu: ArrayList[PerformanceChartPoint] = host_chart_points( + 8, 7, 9, 8, "8 ms", "7 ms", "9 ms", "8 ms"); + let fallback: ArrayList[PerformanceChartPoint] = host_chart_points( + 20, 19, 21, 20, "20 ms", "19 ms", "21 ms", "20 ms"); + let series: ArrayList[PerformanceChartSeries] = + make[PerformanceChartSeries](3); + let x_ticks: ArrayList[[]u8] = make[[]u8](7); + push[[]u8](&x_ticks, "0 s"); push[[]u8](&x_ticks, "5 s"); + push[[]u8](&x_ticks, "10 s"); push[[]u8](&x_ticks, "15 s"); + push[[]u8](&x_ticks, "20 s"); push[[]u8](&x_ticks, "25 s"); + push[[]u8](&x_ticks, "30 s"); + let y_ticks: ArrayList[[]u8] = make[[]u8](7); + push[[]u8](&y_ticks, "24 ms"); push[[]u8](&y_ticks, "20 ms"); + push[[]u8](&y_ticks, "16 ms"); push[[]u8](&y_ticks, "12 ms"); + push[[]u8](&y_ticks, "8 ms"); push[[]u8](&y_ticks, "4 ms"); + push[[]u8](&y_ticks, "0 ms"); + + let cpu_series: PerformanceChartSeries = performance_chart_series( + node_key(7100), "CPU oracle", cpu, + SemanticColorToken.chart_series_primary); + cpu_series.description = "Deterministic reference renderer"; + cpu_series.selected = 1; cpu_series.focused = 1; + push[PerformanceChartSeries](&series, cpu_series); + let gpu_series: PerformanceChartSeries = performance_chart_series( + node_key(7101), "GPU transport", gpu, + SemanticColorToken.chart_series_secondary); + gpu_series.description = "Experimental accelerated transport"; + push[PerformanceChartSeries](&series, gpu_series); + let fallback_series: PerformanceChartSeries = performance_chart_series( + node_key(7102), "Unavailable fallback", fallback, + SemanticColorToken.chart_series_tertiary); + fallback_series.description = "Disabled backend comparison"; + fallback_series.disabled = 1; + push[PerformanceChartSeries](&series, fallback_series); + + let spec: PerformanceChartSpec = performance_chart_spec(node_key(7000), + rect(0, 0, 960 * unit_scale(), 560 * unit_scale()), + series, x_ticks, y_ticks); + spec.title = "Frame pacing performance"; + spec.description = "CPU and GPU frame times across a thirty-second run"; + spec.x_axis_name = "Elapsed time"; spec.x_axis_unit = "seconds"; + spec.y_axis_name = "Frame time"; spec.y_axis_unit = "milliseconds"; + spec.x_min = 0; spec.x_max = 30; spec.y_min = 0; spec.y_max = 24; + spec.x_tick_count = 7; spec.y_tick_count = 7; + spec.baseline_value = 8; spec.baseline_name = "Eight millisecond baseline"; + spec.deadline_value = 16; + spec.deadline_name = "Sixteen millisecond deadline"; + let artifact: PerformanceChartArtifact = performance_chart_build(spec); + + hosted_success_contract(&state, &artifact); + hosted_failure_contract(&state, &artifact); + + performance_chart_free(&artifact); + free[PerformanceChartSeries](&series); + free[PerformanceChartPoint](&fallback); + free[PerformanceChartPoint](&gpu); + free[PerformanceChartPoint](&cpu); + free[[]u8](&y_ticks); free[[]u8](&x_ticks); + _zag_print("Performance chart host contract: pass="); + _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-performance-chart.sh b/tools/test-performance-chart.sh index 26adbb2..e4b27de 100755 --- a/tools/test-performance-chart.sh +++ b/tools/test-performance-chart.sh @@ -10,9 +10,13 @@ if [ ! -x "$znc" ]; then fi binary=$(mktemp /tmp/zagkit-test-performance-chart.XXXXXX) -cleanup() { rm -f -- "$binary"; } +host_binary=$(mktemp /tmp/zagkit-test-performance-chart-host.XXXXXX) +cleanup() { rm -f -- "$binary" "$host_binary"; } trap cleanup EXIT "$znc" "$root/tests/performance_chart_contract.zag" --no-zagd \ --analyze-strict --no-foreground-cache -o "$binary" "$binary" +"$znc" "$root/tests/performance_chart_host_contract.zag" --no-zagd \ + --analyze-strict --no-foreground-cache -o "$host_binary" +"$host_binary" From 078a48d6636176ec37bc24b9c03e66aa830bc1e2 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 07:07:50 -0700 Subject: [PATCH 096/103] Keep disabled Talkback targets queryable --- src/automation/talkback.zag | 7 +++++-- tests/talkback_contract.zag | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/automation/talkback.zag b/src/automation/talkback.zag index 3d8ea59..413303c 100644 --- a/src/automation/talkback.zag +++ b/src/automation/talkback.zag @@ -440,8 +440,11 @@ fn talkback_dispatch(session: *TalkbackSession, tree: SemanticsTree, viewport: R return talkback_record(session, tree.revision, request, TalkbackStatus.target_not_found); } let required_action: i64 = talkback_required_action(request.command); - if (tree.nodes.data[node_index].disabled != 0 || - (required_action != 0 && (tree.nodes.data[node_index].action_mask & required_action) == 0)) { + // Disabled nodes remain discoverable and queryable. Only commands that + // require an action fail closed when that action is unavailable. + if (required_action != 0 && + (tree.nodes.data[node_index].disabled != 0 || + (tree.nodes.data[node_index].action_mask & required_action) == 0)) { return talkback_record(session, tree.revision, request, TalkbackStatus.action_unavailable); } let response: TalkbackResponse = talkback_record(session, tree.revision, request, TalkbackStatus.accepted); diff --git a/tests/talkback_contract.zag b/tests/talkback_contract.zag index 3a51fa1..67235fb 100644 --- a/tests/talkback_contract.zag +++ b/tests/talkback_contract.zag @@ -125,6 +125,25 @@ fn pixel_fallback_contract(state: *TestState) void { semantics_tree_free(&tree); } +fn disabled_query_contract(state: *TestState) void { + let tree: SemanticsTree = make_tree(); + let button_index: i32 = semantics_find_index(tree, node_key(2)); + tree.nodes.data[button_index].disabled = 1; + let session: TalkbackSession = talkback_session_make(talkback_capabilities()); + let viewport: Rect = rect(0, 0, 800 * unit_scale(), 600 * unit_scale()); + let query: TalkbackResponse = talkback_dispatch(&session, tree, viewport, + talkback_request(40, TalkbackCommand.query, node_key(2), tree.revision)); + let click: TalkbackResponse = talkback_dispatch(&session, tree, viewport, + talkback_request(41, TalkbackCommand.click, node_key(2), tree.revision)); + expect(state, query.status == TalkbackStatus.accepted && + (query.state_flags & 1) != 0 && query.emitted_action == 0, + "disabled targets remain discoverable through read-only ID queries"); + expect(state, click.status == TalkbackStatus.action_unavailable, + "disabled targets reject action commands while remaining queryable"); + talkback_session_free(&session); + semantics_tree_free(&tree); +} + fn capability_contract(state: *TestState) void { let tree: SemanticsTree = make_tree(); let capabilities: TalkbackCapabilities = talkback_capabilities(); @@ -148,6 +167,7 @@ fn capability_contract(state: *TestState) void { fn main() i32 { let state: TestState = TestState{ .passed = 0, .failed = 0 }; id_target_contract(&state); + disabled_query_contract(&state); pixel_fallback_contract(&state); capability_contract(&state); let passed_text: []u8 = _zag_i64_to_str(state.passed as i64); From 793ea051987d0f8618a9604ad44ce3ae24919861 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 07:08:00 -0700 Subject: [PATCH 097/103] Declare semantic builder mutation contracts --- src/semantics/semantics.zag | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/semantics/semantics.zag b/src/semantics/semantics.zag index 903b744..9fb6e85 100644 --- a/src/semantics/semantics.zag +++ b/src/semantics/semantics.zag @@ -334,7 +334,8 @@ fn semantics_find_index(tree: SemanticsTree, id: NodeKey) i32 { return 0 - 1; } -fn semantics_fail(tree: *SemanticsTree, error: SemanticsError, id: NodeKey) SemanticsError { +fn semantics_fail(tree: @borrows_mut *SemanticsTree, + error: SemanticsError, id: NodeKey) SemanticsError { tree.*.last_error = error; tree.*.error_node = id; return error; @@ -389,7 +390,8 @@ fn semantics_state_valid(spec: SemanticsSpec) i32 { return 1; } -fn semantics_add(tree: *SemanticsTree, spec: SemanticsSpec) SemanticsError { +fn semantics_add(tree: @borrows_mut *SemanticsTree, + spec: SemanticsSpec) SemanticsError { if (node_key_equal(spec.id, semantic_root_key()) == 1 || node_key_equal(spec.id, semantic_none_key()) == 1) { return semantics_fail(tree, SemanticsError.invalid_id, spec.id); From db4a8383f4fb77d0a6b3c787cd2e4bba1009b3ef Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 07:18:26 -0700 Subject: [PATCH 098/103] Add retained NavigationItem component --- docs/components/navigation-item.md | 66 ++++ src/components/navigation_item.zag | 544 +++++++++++++++++++++++++++++ tests/navigation_item_contract.zag | 354 +++++++++++++++++++ tools/test-navigation-item.sh | 18 + 4 files changed, 982 insertions(+) create mode 100644 docs/components/navigation-item.md create mode 100644 src/components/navigation_item.zag create mode 100644 tests/navigation_item_contract.zag create mode 100755 tools/test-navigation-item.sh diff --git a/docs/components/navigation-item.md b/docs/components/navigation-item.md new file mode 100644 index 0000000..ec9d585 --- /dev/null +++ b/docs/components/navigation-item.md @@ -0,0 +1,66 @@ +# NavigationItem + +`NavigationItem` is Zagkit's retained app-navigation and tab primitive. One +stable `NodeKey` owns its display operations, 44-logical-pixel hit target, +focus target, `tab` semantic, and Talkback target. It does not proxy through a +generic Button semantic. + +The component is experimental. Its executable contract is implemented, but it +is not yet wired into every shell or certified by native platform accessibility +adapters. + +## Interaction contract + +`NavigationItemSpec.interaction` is the canonical `ButtonInteraction`, and +`navigation_item_reduce` delegates to `button_reduce` for every `ButtonEvent`. +Pointer cancellation, focus loss, loading precedence, disabled behavior, and +inside-release activation therefore cannot drift between buttons and +navigation. Application state still owns selection: the item reports an +activation and the selected state flows back down. + +The eight executable fixtures are default, hover, focus, pressed, selected, +disabled, loading, and error. Their colors resolve only through semantic color, +radius, elevation, and Flex spacing tokens. + +## Visible state language + +State is not encoded by color alone: + +- focus emits a retained outer focus ring; +- selection emits a persistent leading marker and `selected` semantic truth; +- loading emits a two-part progress rail, exposes the value `Loading`, and + makes actions unavailable while remaining discoverable; and +- error emits a shaped exclamation badge and exposes the value `Error`. + +Disabled items remain queryable but expose no activate, select, or focus action +and are removed from pointer and focus routing. Enabled items expose all three +actions. The exact focus order, set size, and one-based position in set are +copied into the parallel semantic tree. + +## Placement and Text composition + +Visible bounds, hit bounds, semantic bounds, and Talkback bounds are identical. +Both target dimensions must be at least 44 logical pixels; undersized items +fail validation rather than gaining an invisible hit halo. Internal label +padding and trailing status space come from Flex tokens. + +The component draws chrome and state treatments, then returns +`label_content_bounds`, `SemanticTypeToken.label`, and the resolved semantic +label color token. The caller composes Zagkit `Text` into that slot. This module +does not approximate glyphs, and it does not claim text shaping, bidi, wrapping, +or native font fallback by itself. + +## Failure and evidence contract + +Emission checkpoints the caller-owned `DisplayList`, `HitTree`, and +`SemanticsTree`. Display failure, duplicate IDs, missing parents, and semantic +focus collisions restore all three builders. Invalid UTF-8, collection +metadata, geometry, interaction flags, or minimum target size fail before any +mutation. + +`NavigationItemArtifact.evidence_hash` deterministically covers stable +identity and parent, geometry, labels, focus and set metadata, Text slot tokens, +interaction state, semantic style tokens, and non-color state-treatment truth. +`tests/navigation_item_contract.zag` +verifies the eight-state matrix, immutable display output, semantic tab state, +Talkback query/action behavior, deterministic evidence, and rollback. diff --git a/src/components/navigation_item.zag b/src/components/navigation_item.zag new file mode 100644 index 0000000..a2dbd0a --- /dev/null +++ b/src/components/navigation_item.zag @@ -0,0 +1,544 @@ +@import("button.zag") + +// NavigationItem is the retained tab/navigation primitive. It deliberately +// reuses ButtonInteraction and button_reduce so pointer, focus, loading, and +// error precedence cannot drift from Button. It owns a tab semantic rather +// than forwarding to button_emit, because navigation selection and set +// position are first-class accessibility truth. + +enum NavigationItemBuildError { + none, + invalid_spec, + display_error, + hit_error, + semantics_error, +} + +struct NavigationItemResolvedStyle { + fill_token: SemanticColorToken, + border_token: SemanticColorToken, + label_color_token: SemanticColorToken, + focus_token: SemanticColorToken, + marker_token: SemanticColorToken, + status_token: SemanticColorToken, + fill_alpha: i64, + border_alpha: i64, + radius: i64, + elevation: SemanticElevationToken, +} + +struct NavigationItemSpec { + id: NodeKey, + parent: NodeKey, + bounds: Rect, + label: []u8, + description: []u8, + focus_order: i64, + z_order: i64, + set_size: i64, + position_in_set: i64, + density: FlexDensity, + interaction: ButtonInteraction, +} + +struct NavigationItemArtifact { + id: NodeKey, + bounds: Rect, + label_content_bounds: Rect, + label_type_token: SemanticTypeToken, + label_color_token: SemanticColorToken, + interaction: ButtonInteraction, + style: NavigationItemResolvedStyle, + effectively_enabled: i32, + focus_ring_visible: i32, + selected_marker_visible: i32, + loading_treatment_visible: i32, + error_treatment_visible: i32, + display_op_start: i32, + display_op_end: i32, + hit_node_index: i32, + semantics_node_index: i32, + evidence_hash: i64, +} + +struct NavigationItemBuildResult { + artifact: NavigationItemArtifact, + error: NavigationItemBuildError, + display_error: DisplayError, + hit_error: HitTreeError, + semantics_error: SemanticsError, +} + +struct NavigationItemCheckpoint { + display_length: i32, + display_revision: i64, + display_hash: i64, + display_last_error: DisplayError, + display_error_index: i32, + hit_length: i32, + hit_revision: i64, + hit_last_error: HitTreeError, + hit_error_node: NodeKey, + semantics: SemanticsCheckpoint, +} + +fn navigation_item_minimum_target() i64 { return 44 * unit_scale(); } +fn navigation_item_label_byte_limit() i32 { return 4096; } +fn navigation_item_description_byte_limit() i32 { return 16384; } + +fn navigation_item_spec(id: NodeKey, parent: NodeKey, bounds: Rect, + label: []u8) NavigationItemSpec { + return NavigationItemSpec{ + .id = id, + .parent = parent, + .bounds = bounds, + .label = label, + .description = "", + .focus_order = 0, + .z_order = 0, + .set_size = 1, + .position_in_set = 1, + .density = FlexDensity.standard, + .interaction = button_interaction(), + }; +} + +fn navigation_item_canonical_state(state: ButtonCanonicalState) ButtonInteraction { + return button_canonical_state(state); +} + +fn navigation_item_reduce(current: ButtonInteraction, + event: ButtonEvent) ButtonTransition { + return button_reduce(current, event); +} + +fn navigation_item_style(spec: NavigationItemSpec) NavigationItemResolvedStyle { + let style: NavigationItemResolvedStyle = NavigationItemResolvedStyle{ + .fill_token = SemanticColorToken.surface_base, + .border_token = SemanticColorToken.border_subtle, + .label_color_token = SemanticColorToken.text_primary, + .focus_token = SemanticColorToken.focus, + .marker_token = SemanticColorToken.accent, + .status_token = SemanticColorToken.text_secondary, + .fill_alpha = 5000, + .border_alpha = 0, + .radius = semantic_radius(SemanticRadiusToken.control), + .elevation = SemanticElevationToken.base, + }; + if (spec.interaction.hovered != 0) { + style.fill_token = SemanticColorToken.surface_interactive; + style.fill_alpha = 43000; + style.border_alpha = 12000; + } + if (spec.interaction.selected != 0) { + style.fill_token = SemanticColorToken.surface_selected; + style.border_token = SemanticColorToken.border_emphasis; + style.fill_alpha = 47000; + style.border_alpha = 17000; + style.elevation = SemanticElevationToken.panel; + } + if (spec.interaction.pressed != 0) { + style.fill_token = SemanticColorToken.accent_emphasis; + style.fill_alpha = 50000; + style.elevation = SemanticElevationToken.base; + } + if (spec.interaction.loading != 0) { + style.fill_token = SemanticColorToken.surface_inset; + style.label_color_token = SemanticColorToken.text_secondary; + style.status_token = SemanticColorToken.accent; + style.fill_alpha = 39000; + style.border_alpha = 9000; + style.elevation = SemanticElevationToken.base; + } + if (spec.interaction.error != 0) { + style.fill_token = SemanticColorToken.surface_inset; + style.border_token = SemanticColorToken.status_error; + style.status_token = SemanticColorToken.status_error; + style.fill_alpha = 18000; + style.border_alpha = 65535; + } + if (spec.interaction.enabled == 0) { + style.fill_token = SemanticColorToken.surface_inset; + style.border_token = SemanticColorToken.border_subtle; + style.label_color_token = SemanticColorToken.text_disabled; + style.status_token = SemanticColorToken.text_disabled; + style.fill_alpha = 24000; + style.border_alpha = 7000; + style.elevation = SemanticElevationToken.base; + } + return style; +} + +fn navigation_item_label_bounds(spec: NavigationItemSpec) Rect { + let horizontal: i64 = flex_spacing(FlexSpacingToken.large, spec.density); + let vertical: i64 = flex_spacing(FlexSpacingToken.small, spec.density); + let trailing: i64 = horizontal; + let has_trailing_status: i32 = (spec.interaction.loading != 0 || + spec.interaction.error != 0) as i32; + if (has_trailing_status != 0) { + trailing = flex_spacing(FlexSpacingToken.xxlarge, spec.density) + + flex_spacing(FlexSpacingToken.small, spec.density); + } + let h: i64 = horizontal; + let v: i64 = vertical; + let tail: i64 = trailing; + if (h + tail >= spec.bounds.width) { + h = flex_spacing(FlexSpacingToken.small, spec.density); + if (has_trailing_status != 0) { + tail = spec.bounds.width - h; + } else { + tail = h; + } + } + if (v * 2 >= spec.bounds.height) { + v = (spec.bounds.height - unit_scale()) / 2; + } + if (h < 0) { h = 0; } + if (v < 0) { v = 0; } + if (tail < 0) { tail = 0; } + if (tail > spec.bounds.width - h) { tail = spec.bounds.width - h; } + let width: i64 = spec.bounds.width - h - tail; + if (width < 0) { width = 0; } + return rect(spec.bounds.x + h, spec.bounds.y + v, + width, spec.bounds.height - v * 2); +} + +fn navigation_item_density_valid(density: FlexDensity) i32 { + let value: i64 = density as i64; + return (value >= FlexDensity.compact as i64 && + value <= FlexDensity.touch as i64) as i32; +} + +fn navigation_item_spec_valid(spec: NavigationItemSpec) i32 { + if (node_key_equal(spec.id, hit_root_key()) != 0 || + node_key_equal(spec.id, semantic_none_key()) != 0 || + spec.label.len <= 0 || + spec.label.len > navigation_item_label_byte_limit() || + spec.description.len > navigation_item_description_byte_limit() || + semantic_text_input_valid(spec.label) == 0 || + semantic_text_input_valid(spec.description) == 0 || + spec.focus_order < 0 || + spec.set_size <= 0 || spec.position_in_set <= 0 || + spec.position_in_set > spec.set_size || + navigation_item_density_valid(spec.density) == 0 || + spec.bounds.width < navigation_item_minimum_target() || + spec.bounds.height < navigation_item_minimum_target() || + hit_geometry_valid(spec.bounds) == 0 || + button_interaction_valid(spec.interaction) == 0) { return 0; } + return 1; +} + +fn navigation_item_state_value(spec: NavigationItemSpec) []u8 { + if (spec.interaction.loading != 0) { return "Loading"; } + if (spec.interaction.error != 0) { return "Error"; } + if (spec.interaction.selected != 0) { return "Selected"; } + return ""; +} + +fn navigation_item_hash_mix(hash: i64, value: i64) i64 { + return (hash * 1099511628211) ^ value; +} + +fn navigation_item_artifact_hash(artifact: NavigationItemArtifact) i64 { + let hash: i64 = navigation_item_hash_mix(17, artifact.id.value); + hash = navigation_item_hash_mix(hash, artifact.id.generation); + hash = navigation_item_hash_mix(hash, artifact.bounds.x); + hash = navigation_item_hash_mix(hash, artifact.bounds.y); + hash = navigation_item_hash_mix(hash, artifact.bounds.width); + hash = navigation_item_hash_mix(hash, artifact.bounds.height); + hash = navigation_item_hash_mix(hash, artifact.label_content_bounds.x); + hash = navigation_item_hash_mix(hash, artifact.label_content_bounds.y); + hash = navigation_item_hash_mix(hash, artifact.label_content_bounds.width); + hash = navigation_item_hash_mix(hash, artifact.label_content_bounds.height); + hash = navigation_item_hash_mix(hash, artifact.label_type_token as i64); + hash = navigation_item_hash_mix(hash, artifact.label_color_token as i64); + hash = navigation_item_hash_mix(hash, artifact.interaction.hovered as i64); + hash = navigation_item_hash_mix(hash, artifact.interaction.focused as i64); + hash = navigation_item_hash_mix(hash, artifact.interaction.pressed as i64); + hash = navigation_item_hash_mix(hash, artifact.interaction.selected as i64); + hash = navigation_item_hash_mix(hash, artifact.interaction.enabled as i64); + hash = navigation_item_hash_mix(hash, artifact.interaction.loading as i64); + hash = navigation_item_hash_mix(hash, artifact.interaction.error as i64); + hash = navigation_item_hash_mix(hash, artifact.style.fill_token as i64); + hash = navigation_item_hash_mix(hash, artifact.style.border_token as i64); + hash = navigation_item_hash_mix(hash, artifact.style.label_color_token as i64); + hash = navigation_item_hash_mix(hash, artifact.style.focus_token as i64); + hash = navigation_item_hash_mix(hash, artifact.style.marker_token as i64); + hash = navigation_item_hash_mix(hash, artifact.style.status_token as i64); + hash = navigation_item_hash_mix(hash, artifact.style.fill_alpha); + hash = navigation_item_hash_mix(hash, artifact.style.border_alpha); + hash = navigation_item_hash_mix(hash, artifact.style.radius); + hash = navigation_item_hash_mix(hash, artifact.style.elevation as i64); + hash = navigation_item_hash_mix(hash, artifact.effectively_enabled as i64); + hash = navigation_item_hash_mix(hash, artifact.focus_ring_visible as i64); + hash = navigation_item_hash_mix(hash, artifact.selected_marker_visible as i64); + hash = navigation_item_hash_mix(hash, artifact.loading_treatment_visible as i64); + return navigation_item_hash_mix(hash, artifact.error_treatment_visible as i64); +} + +fn navigation_item_bytes_hash(hash: i64, bytes: []u8) i64 { + let result: i64 = navigation_item_hash_mix(hash, bytes.len as i64); + let index: i32 = 0; + while (index < bytes.len) { + result = navigation_item_hash_mix(result, bytes[index] as i64); + index = index + 1; + } + return result; +} + +fn navigation_item_evidence_hash(spec: NavigationItemSpec, + artifact: NavigationItemArtifact) i64 { + let hash: i64 = navigation_item_artifact_hash(artifact); + hash = navigation_item_hash_mix(hash, spec.parent.value); + hash = navigation_item_hash_mix(hash, spec.parent.generation); + hash = navigation_item_hash_mix(hash, spec.focus_order); + hash = navigation_item_hash_mix(hash, spec.z_order); + hash = navigation_item_hash_mix(hash, spec.set_size); + hash = navigation_item_hash_mix(hash, spec.position_in_set); + hash = navigation_item_bytes_hash(hash, spec.label); + return navigation_item_bytes_hash(hash, spec.description); +} + +fn navigation_item_empty_artifact(spec: NavigationItemSpec) NavigationItemArtifact { + let style: NavigationItemResolvedStyle = navigation_item_style(spec); + return NavigationItemArtifact{ + .id = spec.id, + .bounds = spec.bounds, + .label_content_bounds = rect(0, 0, 0, 0), + .label_type_token = SemanticTypeToken.label, + .label_color_token = style.label_color_token, + .interaction = spec.interaction, + .style = style, + .effectively_enabled = 0, + .focus_ring_visible = 0, + .selected_marker_visible = 0, + .loading_treatment_visible = 0, + .error_treatment_visible = 0, + .display_op_start = 0 - 1, + .display_op_end = 0 - 1, + .hit_node_index = 0 - 1, + .semantics_node_index = 0 - 1, + .evidence_hash = 0, + }; +} + +fn navigation_item_build_result(spec: NavigationItemSpec, + error: NavigationItemBuildError) NavigationItemBuildResult { + return NavigationItemBuildResult{ + .artifact = navigation_item_empty_artifact(spec), + .error = error, + .display_error = DisplayError.none, + .hit_error = HitTreeError.none, + .semantics_error = SemanticsError.none, + }; +} + +fn navigation_item_checkpoint(display: DisplayList, hits: HitTree, + semantics: SemanticsTree) NavigationItemCheckpoint { + return NavigationItemCheckpoint{ + .display_length = display.ops.len, + .display_revision = display.revision, + .display_hash = display.content_hash, + .display_last_error = display.last_error, + .display_error_index = display.error_index, + .hit_length = hits.nodes.len, + .hit_revision = hits.revision, + .hit_last_error = hits.last_error, + .hit_error_node = hits.error_node, + .semantics = semantics_tree_checkpoint(semantics), + }; +} + +fn navigation_item_rollback(display: *DisplayList, hits: *HitTree, + semantics: *SemanticsTree, checkpoint: NavigationItemCheckpoint) void { + display.*.ops.len = checkpoint.display_length; + display.*.revision = checkpoint.display_revision; + display.*.content_hash = checkpoint.display_hash; + display.*.last_error = checkpoint.display_last_error; + display.*.error_index = checkpoint.display_error_index; + hits.*.nodes.len = checkpoint.hit_length; + hits.*.revision = checkpoint.hit_revision; + hits.*.last_error = checkpoint.hit_last_error; + hits.*.error_node = checkpoint.hit_error_node; + _ = semantics_tree_rollback(semantics, checkpoint.semantics); +} + +fn navigation_item_push_round(display: *DisplayList, id: NodeKey, + bounds: Rect, radius: i64, paint: Paint) DisplayError { + let op: DisplayOp = display_op(DisplayOpKind.fill_rounded_rect, id); + op.bounds = bounds; + op.parameter = radius; + op.paint = paint; + return display_list_push(display, op); +} + +fn navigation_item_emit(spec: NavigationItemSpec, display: *DisplayList, + hits: *HitTree, semantics: *SemanticsTree) NavigationItemBuildResult { + let result: NavigationItemBuildResult = navigation_item_build_result( + spec, NavigationItemBuildError.none); + if (navigation_item_spec_valid(spec) == 0 || display.*.sealed != 0) { + result.error = NavigationItemBuildError.invalid_spec; + if (display.*.sealed != 0) { + result.error = NavigationItemBuildError.display_error; + result.display_error = DisplayError.sealed; + } + return result; + } + + let checkpoint: NavigationItemCheckpoint = navigation_item_checkpoint( + display.*, hits.*, semantics.*); + let style: NavigationItemResolvedStyle = navigation_item_style(spec); + let elevation: ResolvedElevationToken = semantic_elevation(style.elevation); + let enabled: i32 = button_effectively_enabled(spec.interaction); + let op_start: i32 = display.*.ops.len; + let display_error: DisplayError = DisplayError.none; + + if (spec.interaction.focused != 0 && enabled != 0) { + let extent: i64 = 3 * unit_scale(); + display_error = navigation_item_push_round(display, spec.id, + rect(spec.bounds.x - extent, spec.bounds.y - extent, + spec.bounds.width + extent * 2, + spec.bounds.height + extent * 2), + style.radius + extent, + semantic_color_with_alpha(style.focus_token, 42000)); + } + if (display_error == DisplayError.none && elevation.shadow_alpha > 0 && + spec.interaction.pressed == 0 && enabled != 0) { + let spread: i64 = elevation.shadow_spread; + display_error = navigation_item_push_round(display, spec.id, + rect(spec.bounds.x - spread, + spec.bounds.y + elevation.shadow_offset_y - spread, + spec.bounds.width + spread * 2, + spec.bounds.height + spread * 2), + style.radius + spread, + semantic_color_with_alpha(SemanticColorToken.shadow, + elevation.shadow_alpha)); + } + if (display_error == DisplayError.none && style.border_alpha > 0) { + display_error = navigation_item_push_round(display, spec.id, + spec.bounds, style.radius, + semantic_color_with_alpha(style.border_token, style.border_alpha)); + } + if (display_error == DisplayError.none && style.fill_alpha > 0) { + let edge: i64 = unit_scale(); + display_error = navigation_item_push_round(display, spec.id, + rect(spec.bounds.x + edge, spec.bounds.y + edge, + spec.bounds.width - edge * 2, + spec.bounds.height - edge * 2), + style.radius - edge, + semantic_color_with_alpha(style.fill_token, style.fill_alpha)); + } + if (display_error == DisplayError.none && spec.interaction.selected != 0) { + let marker_width: i64 = 4 * unit_scale(); + let marker_height: i64 = 24 * unit_scale(); + display_error = navigation_item_push_round(display, spec.id, + rect(spec.bounds.x + 4 * unit_scale(), + spec.bounds.y + (spec.bounds.height - marker_height) / 2, + marker_width, marker_height), marker_width / 2, + semantic_color_with_alpha(style.marker_token, 65535)); + } + if (display_error == DisplayError.none && spec.interaction.loading != 0) { + let rail_width: i64 = 16 * unit_scale(); + let rail_height: i64 = 3 * unit_scale(); + let rail_x: i64 = spec.bounds.x + spec.bounds.width - + flex_spacing(FlexSpacingToken.large, spec.density) - rail_width; + let rail_y: i64 = spec.bounds.y + (spec.bounds.height - rail_height) / 2; + display_error = navigation_item_push_round(display, spec.id, + rect(rail_x, rail_y, rail_width, rail_height), rail_height / 2, + semantic_color_with_alpha(style.status_token, 26000)); + if (display_error == DisplayError.none) { + display_error = navigation_item_push_round(display, spec.id, + rect(rail_x, rail_y, rail_width / 2, rail_height), + rail_height / 2, + semantic_color_with_alpha(style.status_token, 65535)); + } + } + if (display_error == DisplayError.none && spec.interaction.error != 0) { + let badge: i64 = 14 * unit_scale(); + let badge_x: i64 = spec.bounds.x + spec.bounds.width - + flex_spacing(FlexSpacingToken.large, spec.density) - badge; + let badge_y: i64 = spec.bounds.y + (spec.bounds.height - badge) / 2; + display_error = navigation_item_push_round(display, spec.id, + rect(badge_x, badge_y, badge, badge), badge / 2, + semantic_color_with_alpha(style.status_token, 65535)); + if (display_error == DisplayError.none) { + display_error = navigation_item_push_round(display, spec.id, + rect(badge_x + 6 * unit_scale(), badge_y + 3 * unit_scale(), + 2 * unit_scale(), 5 * unit_scale()), unit_scale(), + semantic_color_with_alpha(SemanticColorToken.surface_base, 65535)); + } + if (display_error == DisplayError.none) { + display_error = navigation_item_push_round(display, spec.id, + rect(badge_x + 6 * unit_scale(), badge_y + 10 * unit_scale(), + 2 * unit_scale(), 2 * unit_scale()), unit_scale(), + semantic_color_with_alpha(SemanticColorToken.surface_base, 65535)); + } + } + if (display_error != DisplayError.none) { + navigation_item_rollback(display, hits, semantics, checkpoint); + result.error = NavigationItemBuildError.display_error; + result.display_error = display_error; + return result; + } + + let hit: HitNode = hit_node(spec.id, spec.parent, spec.bounds); + hit.clip = spec.bounds; + hit.has_clip = 1; + hit.z_order = spec.z_order; + hit.enabled = enabled; + hit.focusable = enabled; + let hit_error: HitTreeError = hit_tree_add(hits, hit); + if (hit_error != HitTreeError.none) { + navigation_item_rollback(display, hits, semantics, checkpoint); + result.error = NavigationItemBuildError.hit_error; + result.hit_error = hit_error; + return result; + } + + let semantic: SemanticsSpec = semantics_spec(spec.id, spec.parent, + SemanticRole.tab, spec.label); + semantic.description = spec.description; + semantic.value = navigation_item_state_value(spec); + semantic.focus_order = spec.focus_order; + semantic.disabled = (enabled == 0) as i32; + semantic.selected = spec.interaction.selected; + semantic.set_size = spec.set_size; + semantic.position_in_set = spec.position_in_set; + semantic.has_bounds = 1; + semantic.bounds = spec.bounds; + if (enabled != 0) { + semantic.action_mask = semantic_actions_add( + semantic_actions_add(semantic_actions(SemanticAction.activate), + SemanticAction.select), SemanticAction.focus); + } + let semantic_error: SemanticsError = semantics_add(semantics, semantic); + if (semantic_error != SemanticsError.none) { + navigation_item_rollback(display, hits, semantics, checkpoint); + result.error = NavigationItemBuildError.semantics_error; + result.semantics_error = semantic_error; + return result; + } + + result.artifact = NavigationItemArtifact{ + .id = spec.id, + .bounds = spec.bounds, + .label_content_bounds = navigation_item_label_bounds(spec), + .label_type_token = SemanticTypeToken.label, + .label_color_token = style.label_color_token, + .interaction = spec.interaction, + .style = style, + .effectively_enabled = enabled, + .focus_ring_visible = (spec.interaction.focused != 0 && enabled != 0) as i32, + .selected_marker_visible = spec.interaction.selected, + .loading_treatment_visible = spec.interaction.loading, + .error_treatment_visible = spec.interaction.error, + .display_op_start = op_start, + .display_op_end = display.*.ops.len, + .hit_node_index = hits.*.nodes.len - 1, + .semantics_node_index = semantics.*.nodes.len - 1, + .evidence_hash = 0, + }; + result.artifact.evidence_hash = navigation_item_evidence_hash( + spec, result.artifact); + return result; +} diff --git a/tests/navigation_item_contract.zag b/tests/navigation_item_contract.zag new file mode 100644 index 0000000..763f7bf --- /dev/null +++ b/tests/navigation_item_contract.zag @@ -0,0 +1,354 @@ +@import("../src/components/navigation_item.zag") +@import("../src/automation/talkback.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn make_navigation_item(id: i64, + canonical: ButtonCanonicalState) NavigationItemSpec { + let spec: NavigationItemSpec = navigation_item_spec(node_key(id), + hit_root_key(), rect(20 * unit_scale(), + (20 + (id - 100) * 52) * unit_scale(), + 188 * unit_scale(), 44 * unit_scale()), "Components"); + spec.description = "Opens the component catalog"; + spec.focus_order = id - 99; + spec.z_order = id; + spec.set_size = 8; + spec.position_in_set = ((id - 100) % 8) + 1; + spec.interaction = navigation_item_canonical_state(canonical); + return spec; +} + +fn reducer_contract(state: *TestState) void { + let initial: ButtonInteraction = button_interaction(); + let entered: ButtonTransition = navigation_item_reduce(initial, + ButtonEvent.pointer_enter); + let pressed: ButtonTransition = navigation_item_reduce(entered.interaction, + ButtonEvent.pointer_down); + let activated: ButtonTransition = navigation_item_reduce(pressed.interaction, + ButtonEvent.pointer_up_inside); + let direct: ButtonTransition = button_reduce(pressed.interaction, + ButtonEvent.pointer_up_inside); + expect(state, entered.interaction.hovered == 1 && + pressed.interaction.pressed == 1 && activated.activate == 1 && + button_interaction_equal(activated.interaction, direct.interaction) != 0, + "NavigationItem delegates interaction and activation to the canonical Button reducer"); + let loading: ButtonTransition = navigation_item_reduce(pressed.interaction, + ButtonEvent.begin_loading); + let ignored: ButtonTransition = navigation_item_reduce(loading.interaction, + ButtonEvent.pointer_down); + expect(state, loading.interaction.loading == 1 && + loading.interaction.pressed == 0 && ignored.interaction.pressed == 0, + "loading precedence remains identical to Button and rejects new presses"); +} + +fn minimum_and_content_contract(state: *TestState) void { + let exact: NavigationItemSpec = navigation_item_spec(node_key(80), + hit_root_key(), rect(0, 0, 44 * unit_scale(), 44 * unit_scale()), "A"); + expect(state, navigation_item_minimum_target() == 44 * unit_scale() && + navigation_item_spec_valid(exact) != 0, + "NavigationItem retains an exact 44 logical-pixel minimum target"); + let too_short: NavigationItemSpec = exact; + too_short.bounds.height = 43 * unit_scale(); + expect(state, navigation_item_spec_valid(too_short) == 0, + "sub-44-pixel targets fail before retained tree mutation"); + let standard: NavigationItemSpec = make_navigation_item(100, + ButtonCanonicalState.rest); + let content: Rect = navigation_item_label_bounds(standard); + let loading: NavigationItemSpec = make_navigation_item(100, + ButtonCanonicalState.loading); + let loading_content: Rect = navigation_item_label_bounds(loading); + let style: NavigationItemResolvedStyle = navigation_item_style(standard); + expect(state, content.x == standard.bounds.x + 16 * unit_scale() && + content.y == standard.bounds.y + 8 * unit_scale() && + content.width == standard.bounds.width - 32 * unit_scale() && + loading_content.width == standard.bounds.width - 56 * unit_scale() && + loading_content.x + loading_content.width + 8 * unit_scale() == + standard.bounds.x + standard.bounds.width - 32 * unit_scale() && + style.label_color_token == SemanticColorToken.text_primary, + "label content exposes exact Flex spacing and a semantic Text color token"); +} + +fn state_matrix_contract(state: *TestState) void { + let display: DisplayList = display_list_make(); + let hits: HitTree = hit_tree_make(); + let semantics: SemanticsTree = semantics_tree_make(); + let aggregate_hash: i64 = 17; + let previous_hash: i64 = 0; + let index: i32 = 0; + while (index < 8) { + let spec: NavigationItemSpec = make_navigation_item(100 + index, + index as ButtonCanonicalState); + let built: NavigationItemBuildResult = navigation_item_emit(spec, + &display, &hits, &semantics); + expect(state, built.error == NavigationItemBuildError.none && + built.artifact.id.value == 100 + index && + built.artifact.hit_node_index == index && + built.artifact.semantics_node_index == index, + "each canonical NavigationItem fixture shares one stable retained identity"); + let op_index: i32 = built.artifact.display_op_start; + let owners_match: i32 = 1; + while (op_index < built.artifact.display_op_end) { + if (node_key_equal(display.ops.data[op_index].owner, spec.id) == 0) { + owners_match = 0; + } + op_index = op_index + 1; + } + expect(state, owners_match != 0 && + node_key_equal(hits.nodes.data[index].id, spec.id) != 0 && + node_key_equal(semantics.nodes.data[index].id, spec.id) != 0, + "display hit and semantic artifacts use the exact same NodeKey"); + expect(state, semantics.nodes.data[index].role == SemanticRole.tab && + semantics.nodes.data[index].set_size == 8 && + semantics.nodes.data[index].position_in_set == index + 1 && + semantics.nodes.data[index].focus_order == index + 1, + "tab role focus order and set position are executable semantic truth"); + expect(state, built.artifact.evidence_hash != 0 && + (index == 0 || built.artifact.evidence_hash != previous_hash), + "each canonical state contributes distinct deterministic component evidence"); + previous_hash = built.artifact.evidence_hash; + aggregate_hash = navigation_item_hash_mix(aggregate_hash, + built.artifact.evidence_hash); + index = index + 1; + } + expect(state, display.ops.len > 8 && hits.nodes.len == 8 && + semantics.nodes.len == 8 && aggregate_hash != 0, + "all eight state fixtures contribute complete deterministic retained evidence"); + + let focus: SemanticsNode = semantics.nodes.data[2]; + let selected: SemanticsNode = semantics.nodes.data[4]; + let disabled: SemanticsNode = semantics.nodes.data[5]; + let loading: SemanticsNode = semantics.nodes.data[6]; + let error: SemanticsNode = semantics.nodes.data[7]; + expect(state, semantic_actions_has(focus.action_mask, + SemanticAction.activate) != 0 && + semantic_actions_has(focus.action_mask, SemanticAction.select) != 0 && + semantic_actions_has(focus.action_mask, SemanticAction.focus) != 0, + "enabled tabs expose activate select and focus actions"); + expect(state, selected.selected == 1 && + semantic_text_equal(selected.value, "Selected") != 0, + "selected NavigationItem state is semantic and textual, not fill color alone"); + expect(state, disabled.disabled == 1 && disabled.action_mask == 0 && + hits.nodes.data[5].enabled == 0 && hits.nodes.data[5].focusable == 0, + "disabled state agrees across semantics actions focus and hit testing"); + expect(state, loading.disabled == 1 && loading.action_mask == 0 && + semantic_text_equal(loading.value, "Loading") != 0, + "loading stays discoverable while its navigation action fails closed"); + expect(state, error.disabled == 0 && + semantic_actions_has(error.action_mask, SemanticAction.activate) != 0, + "error state remains actionable"); + expect(state, semantic_text_equal(error.value, "Error") != 0, + "error state exposes a non-color semantic value"); + + let focus_style: NavigationItemResolvedStyle = navigation_item_style( + make_navigation_item(120, ButtonCanonicalState.focus)); + let selected_style: NavigationItemResolvedStyle = navigation_item_style( + make_navigation_item(121, ButtonCanonicalState.selected)); + let error_style: NavigationItemResolvedStyle = navigation_item_style( + make_navigation_item(122, ButtonCanonicalState.error)); + expect(state, focus_style.focus_token == SemanticColorToken.focus && + selected_style.marker_token == SemanticColorToken.accent && + error_style.status_token == SemanticColorToken.status_error, + "state visuals route through named focus marker and error tokens"); + + let found: HitResult = hit_test(hits, 30 * unit_scale(), + 30 * unit_scale()); + expect(state, found.found != 0 && found.id.value == 100, + "pointer routing resolves the same stable tab ID"); + expect(state, display_list_seal(&display) == DisplayError.none && + display_list_verify(display) == DisplayError.none, + "NavigationItem chrome seals as an immutable verified DisplayList"); + + let session: TalkbackSession = talkback_session_make(talkback_capabilities()); + let viewport: Rect = rect(0, 0, 400 * unit_scale(), 500 * unit_scale()); + let query: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(1, TalkbackCommand.query, node_key(104), + semantics.revision)); + let click: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(2, TalkbackCommand.click, node_key(100), + semantics.revision)); + let disabled_query: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(3, TalkbackCommand.query, node_key(105), + semantics.revision)); + let disabled_click: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(4, TalkbackCommand.click, node_key(105), + semantics.revision)); + expect(state, query.status == TalkbackStatus.accepted && + query.resolved_role == SemanticRole.tab && (query.state_flags & 2) != 0, + "Talkback queries selected tab truth by stable ID without pixels"); + expect(state, click.status == TalkbackStatus.accepted && + click.emitted_action == 1, + "Talkback activates an enabled NavigationItem through its semantic ID"); + expect(state, disabled_query.status == TalkbackStatus.accepted && + (disabled_query.state_flags & 1) != 0 && + disabled_click.status == TalkbackStatus.action_unavailable && + disabled_click.emitted_action == 0, + "Talkback discovers disabled tabs while observing fail-closed action truth"); + + talkback_session_free(&session); + semantics_tree_free(&semantics); + hit_tree_free(&hits); + display_list_free(&display); +} + +fn visible_treatment_contract(state: *TestState) void { + let display: DisplayList = display_list_make(); + let hits: HitTree = hit_tree_make(); + let semantics: SemanticsTree = semantics_tree_make(); + let focus: NavigationItemBuildResult = navigation_item_emit( + make_navigation_item(140, ButtonCanonicalState.focus), + &display, &hits, &semantics); + let selected: NavigationItemBuildResult = navigation_item_emit( + make_navigation_item(141, ButtonCanonicalState.selected), + &display, &hits, &semantics); + let loading: NavigationItemBuildResult = navigation_item_emit( + make_navigation_item(142, ButtonCanonicalState.loading), + &display, &hits, &semantics); + let error: NavigationItemBuildResult = navigation_item_emit( + make_navigation_item(143, ButtonCanonicalState.error), + &display, &hits, &semantics); + expect(state, focus.artifact.focus_ring_visible == 1 && + focus.artifact.display_op_end > focus.artifact.display_op_start && + display.ops.data[focus.artifact.display_op_start].bounds.width == + focus.artifact.bounds.width + 6 * unit_scale(), + "focus produces a retained ring outside the fill treatment"); + expect(state, selected.artifact.selected_marker_visible == 1 && + selected.artifact.display_op_end - selected.artifact.display_op_start >= 3 && + display.ops.data[selected.artifact.display_op_end - 1].bounds.width == + 4 * unit_scale() && + display.ops.data[selected.artifact.display_op_end - 1].bounds.height == + 24 * unit_scale(), + "selection produces a persistent geometric marker"); + expect(state, loading.artifact.loading_treatment_visible == 1 && + loading.artifact.display_op_end - loading.artifact.display_op_start >= 4 && + display.ops.data[loading.artifact.display_op_end - 2].bounds.width == + 16 * unit_scale() && + display.ops.data[loading.artifact.display_op_end - 1].bounds.width == + 8 * unit_scale(), + "loading produces a two-part progress rail rather than a color-only fill"); + expect(state, error.artifact.error_treatment_visible == 1 && + error.artifact.display_op_end - error.artifact.display_op_start >= 5 && + display.ops.data[error.artifact.display_op_end - 3].bounds.width == + 14 * unit_scale() && + display.ops.data[error.artifact.display_op_end - 2].bounds.width == + 2 * unit_scale(), + "error produces a shaped exclamation badge in addition to the error color"); + expect(state, focus.artifact.label_type_token == SemanticTypeToken.label && + focus.artifact.label_color_token == SemanticColorToken.text_primary && + focus.artifact.label_content_bounds.width > 0, + "artifacts return exact type color and bounds for first-party Text composition"); + semantics_tree_free(&semantics); + hit_tree_free(&hits); + display_list_free(&display); +} + +fn deterministic_hash_contract(state: *TestState) void { + let display_a: DisplayList = display_list_make(); + let hits_a: HitTree = hit_tree_make(); + let semantics_a: SemanticsTree = semantics_tree_make(); + let display_b: DisplayList = display_list_make(); + let hits_b: HitTree = hit_tree_make(); + let semantics_b: SemanticsTree = semantics_tree_make(); + let spec: NavigationItemSpec = make_navigation_item(160, + ButtonCanonicalState.selected); + let first: NavigationItemBuildResult = navigation_item_emit(spec, + &display_a, &hits_a, &semantics_a); + let second: NavigationItemBuildResult = navigation_item_emit(spec, + &display_b, &hits_b, &semantics_b); + expect(state, first.error == NavigationItemBuildError.none && + second.error == NavigationItemBuildError.none && + first.artifact.evidence_hash != 0 && + first.artifact.evidence_hash == -8023176794990020306 && + first.artifact.evidence_hash == second.artifact.evidence_hash && + display_a.content_hash == display_b.content_hash && + talkback_tree_hash(semantics_a) == talkback_tree_hash(semantics_b), + "identical NavigationItem inputs produce identical component display and semantic evidence"); + semantics_tree_free(&semantics_b); hit_tree_free(&hits_b); + display_list_free(&display_b); + semantics_tree_free(&semantics_a); hit_tree_free(&hits_a); + display_list_free(&display_a); +} + +fn atomicity_contract(state: *TestState) void { + let display: DisplayList = display_list_make(); + let hits: HitTree = hit_tree_make(); + let semantics: SemanticsTree = semantics_tree_make(); + let first: NavigationItemBuildResult = navigation_item_emit( + make_navigation_item(180, ButtonCanonicalState.rest), + &display, &hits, &semantics); + let op_count: i32 = display.ops.len; + let display_hash: i64 = display.content_hash; + let hit_count: i32 = hits.nodes.len; + let semantic_count: i32 = semantics.nodes.len; + let duplicate: NavigationItemBuildResult = navigation_item_emit( + make_navigation_item(180, ButtonCanonicalState.hover), + &display, &hits, &semantics); + expect(state, first.error == NavigationItemBuildError.none && + duplicate.error == NavigationItemBuildError.hit_error && + duplicate.hit_error == HitTreeError.duplicate_id, + "duplicate stable IDs fail visibly at the retained hit tree"); + expect(state, display.ops.len == op_count && + display.content_hash == display_hash && hits.nodes.len == hit_count && + semantics.nodes.len == semantic_count, + "hit failure atomically restores display hit and semantic builders"); + + let colliding: NavigationItemSpec = make_navigation_item(181, + ButtonCanonicalState.selected); + colliding.focus_order = 81; + colliding.position_in_set = 2; + let collision: NavigationItemBuildResult = navigation_item_emit(colliding, + &display, &hits, &semantics); + expect(state, collision.error == NavigationItemBuildError.semantics_error && + collision.semantics_error == SemanticsError.duplicate_focus_order, + "semantic focus collisions remain structured component errors"); + expect(state, display.ops.len == op_count && hits.nodes.len == hit_count && + semantics.nodes.len == semantic_count, + "semantic failure also restores all retained builders"); + + let invalid: NavigationItemSpec = make_navigation_item(182, + ButtonCanonicalState.rest); + invalid.set_size = 0; + invalid.position_in_set = 0; + expect(state, navigation_item_emit(invalid, &display, &hits, + &semantics).error == NavigationItemBuildError.invalid_spec && + display.ops.len == op_count, + "invalid set metadata fails before any retained mutation"); + + _ = display_list_seal(&display); + let sealed: NavigationItemBuildResult = navigation_item_emit( + make_navigation_item(183, ButtonCanonicalState.rest), + &display, &hits, &semantics); + expect(state, sealed.error == NavigationItemBuildError.display_error && + sealed.display_error == DisplayError.sealed && + display.ops.len == op_count && hits.nodes.len == hit_count && + semantics.nodes.len == semantic_count, + "sealed display failure leaves every destination unchanged"); + semantics_tree_free(&semantics); + hit_tree_free(&hits); + display_list_free(&display); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + reducer_contract(&state); + minimum_and_content_contract(&state); + state_matrix_contract(&state); + visible_treatment_contract(&state); + deterministic_hash_contract(&state); + atomicity_contract(&state); + _zag_print("NavigationItem contract: pass="); + _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); + _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/test-navigation-item.sh b/tools/test-navigation-item.sh new file mode 100755 index 0000000..a30dba0 --- /dev/null +++ b/tools/test-navigation-item.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +znc=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} + +if [ ! -x "$znc" ]; then + printf 'navigation item contract: FAIL: Zag compiler not executable: %s\n' "$znc" >&2 + exit 2 +fi + +binary=$(mktemp /tmp/zagkit-test-navigation-item.XXXXXX) +cleanup() { rm -f -- "$binary"; } +trap cleanup EXIT + +"$znc" "$root/tests/navigation_item_contract.zag" --no-zagd \ + --analyze-strict --no-foreground-cache -o "$binary" +"$binary" From 75f3d5c348ee30decbb0f5b29bbb694487a16a63 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 07:18:48 -0700 Subject: [PATCH 099/103] Inventory the NavigationItem primitive --- contracts/components.json | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/components.json b/contracts/components.json index 40f3f2d..1dd9fcf 100644 --- a/contracts/components.json +++ b/contracts/components.json @@ -47,6 +47,7 @@ {"id":"picker","family":"input","name":"Picker","milestone":2,"status":"planned","semantic_roles":["combobox","listbox"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","direction","text_scale"]}, {"id":"color-picker","family":"input","name":"ColorPicker","milestone":4,"status":"planned","semantic_roles":["group","slider"],"inputs":["pointer","keyboard","touch","pen"],"adaptive":["density","platform","contrast"]}, {"id":"link","family":"navigation","name":"Link","milestone":2,"status":"planned","semantic_roles":["link"],"inputs":["pointer","keyboard","touch"],"adaptive":["platform","contrast","direction"]}, + {"id":"navigation-item","family":"navigation","name":"NavigationItem","milestone":2,"status":"implementing","semantic_roles":["tab"],"inputs":["pointer","keyboard","touch","gamepad","accessibility_action","zagkit_talkback"],"adaptive":["density","contrast","text_scale"],"source":"src/components/navigation_item.zag","evidence":"tests/navigation_item_contract.zag","documentation":"docs/components/navigation-item.md"}, {"id":"tabs","family":"navigation","name":"Tabs","milestone":2,"status":"planned","semantic_roles":["tab","tablist","tabpanel"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","direction","text_scale"]}, {"id":"navigation-stack","family":"navigation","name":"NavigationStack","milestone":2,"status":"planned","semantic_roles":["navigation"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["breakpoint","platform","direction","reduced_motion"]}, {"id":"split-view","family":"navigation","name":"SplitView","milestone":2,"status":"planned","semantic_roles":["navigation","separator"],"inputs":["pointer","keyboard","touch"],"adaptive":["breakpoint","platform","direction","text_scale"]}, From abe23151470778ab22786146379603659604e2c7 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 08:23:09 -0700 Subject: [PATCH 100/103] Honor segmented control placement minimum --- src/showcase/linux_preview_layout.zag | 2 +- tests/linux_preview_layout_contract.zag | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/showcase/linux_preview_layout.zag b/src/showcase/linux_preview_layout.zag index 39d3329..d4eec6e 100644 --- a/src/showcase/linux_preview_layout.zag +++ b/src/showcase/linux_preview_layout.zag @@ -206,7 +206,7 @@ fn linux_preview_layout(width: i32, height: i32) LinuxPreviewLayout { flex_spacing(FlexSpacingToken.large, density); let control_height: i64 = flex_spacing(FlexSpacingToken.xxlarge, density) + - flex_spacing(FlexSpacingToken.medium, density); + flex_spacing(FlexSpacingToken.large, density); let inner_width: i64 = result.main.width - result.panel_padding * 2; let inner_height: i64 = result.main.height - result.panel_padding * 2; result.page_heading = rect(result.main.x + result.panel_padding, diff --git a/tests/linux_preview_layout_contract.zag b/tests/linux_preview_layout_contract.zag index 16b3458..0b04c5f 100644 --- a/tests/linux_preview_layout_contract.zag +++ b/tests/linux_preview_layout_contract.zag @@ -33,6 +33,7 @@ fn main() i32 { "expanded placement has one navigation column and a status-only rail"); expect(&state, expanded.chart_visible == 1 && expanded.segmented_control_visible == 1 && + expanded.segmented_control.height >= 48 * unit_scale() && expanded.chart.width >= 640 * unit_scale() && expanded.chart.height >= 420 * unit_scale(), "expanded placement honors the chart component minimum"); @@ -87,7 +88,7 @@ fn main() i32 { expect(&state, tight.error == LinuxPreviewLayoutError.none && tight.mode == LinuxPreviewLayoutMode.medium && tight.chart.width == 656 * unit_scale() && - tight.chart.height == 436 * unit_scale(), + tight.chart.height == 432 * unit_scale(), "tight medium placement preserves the chart contract without clipping"); let compact: LinuxPreviewLayout = linux_preview_layout(800, 600); From 48bbaef09690f457942a6159fae489a2f5cbeeed Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 09:01:12 -0700 Subject: [PATCH 101/103] Correct Talkback completion truth --- GOAL.md | 6 +- docs/evidence/agent-checklist-2026-08-09.md | 104 ++++++++++++++++++++ tools/emit-agent-checklist.sh | 3 + 3 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 docs/evidence/agent-checklist-2026-08-09.md diff --git a/GOAL.md b/GOAL.md index 159d0f2..5661053 100644 --- a/GOAL.md +++ b/GOAL.md @@ -82,9 +82,9 @@ more precision is needed and preserve its original ID as the parent. - [ ] `G4-GESTURES` Implement gesture arbitration, velocity, drag and drop, and handoff. — Exit: nested recognizer continuity and cleanup suites pass - [x] `G4-TALKBACK-PROTOCOL` Specify the versioned Zagkit Talkback native automation protocol. — Evidence: [protocol contract](contracts/talkback-protocol.json), [talkback contract](tests/talkback_contract.zag), [talkback docs](docs/automation/talkback.md), [headless contract](docs/evidence/goal-progress-2026-08-07.md) - [x] `G4-TALKBACK-IDS` Expose stable developer-assigned and deterministic generated node IDs. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) -- [x] `G4-TALKBACK-ACTIONS` Support semantic query, click, type, scroll, drag, focus, wait, assert, snapshot, and replay actions. — Evidence: [talkback contract](tests/talkback_contract.zag), [goal progress checkpoint](docs/evidence/goal-progress-2026-08-07.md), [current session](docs/evidence/goal-progress-2026-08-07.md) -- [x] `G4-TALKBACK-PIXELS` Provide an explicit pixel-coordinate fallback. — Evidence: [goal progress checkpoint 2026-08-07](docs/evidence/goal-progress-2026-08-07.md) -- [x] `G4-TALKBACK-INSPECT` Ship tree, layout-reason, screenshot, timeline, and capability inspection. — Evidence: [talkback contract](tests/talkback_contract.zag), [talkback docs](docs/automation/talkback.md), [goal progress](docs/evidence/goal-progress-2026-08-07.md), [headless contract](docs/evidence/talkback-contract-2026-08-07.log) +- [ ] `G4-TALKBACK-ACTIONS` Execute semantic query, click, type, scroll, drag, focus, key, gesture, wait, assert, snapshot, and replay against live applications. — Exit: the native transport drives each command, observes the resulting app state, rejects stale/disabled targets, and retains deterministic evidence; in-process event emission alone does not satisfy this row +- [ ] `G4-TALKBACK-PIXELS` Provide an explicit native pixel-coordinate fallback. — Exit: a capability-gated platform driver consumes scale-aware coordinates, records ID-derived provenance when present without relabeling the action, executes the fallback, and proves bounds/error behavior; the in-process resolver alone does not satisfy this row +- [ ] `G4-TALKBACK-INSPECT` Ship tree, layout-reason, screenshot, timeline, and capability inspection. — Exit: every inspector is available through the native transport and agrees with retained runtime truth; screenshots and native transport are currently reported unavailable - [x] `G4-TALKBACK-NAME` Keep Zagkit Talkback distinct from Android TalkBack accessibility. — Evidence: [RFC-0006 naming rule](docs/rfcs/0006-flex-talkback-visual-fidelity-and-prismstudio.md), [protocol contract](contracts/talkback-protocol.json), [Talkback docs](docs/automation/talkback.md) - [ ] `G4-ACCESSIBILITY` Implement Linux AT-SPI, Apple accessibility, Windows UIA, and Android accessibility adapters. — Exit: native assistive-technology suites pass on every promoted platform - [ ] `G4-CLI` Implement `zagkit init`, `build`, `run`, and `test`. — Exit: reproducible clean-project workflows pass on every promoted target diff --git a/docs/evidence/agent-checklist-2026-08-09.md b/docs/evidence/agent-checklist-2026-08-09.md new file mode 100644 index 0000000..8685df0 --- /dev/null +++ b/docs/evidence/agent-checklist-2026-08-09.md @@ -0,0 +1,104 @@ +# Zagkit execution checklist (agent-facing) + +- Generated: 2026-08-09T08:59:06-07:00 +- Source of truth: GOAL.md +- Evidence inputs: GOAL.md, contracts/upstream-zag.json, contracts/platforms.json + +## Blocked items, in checklist order + +### 0. Repository and product contract +- [ ] `G0-VISUAL-DIRECTION`: blocked until full visual-matrix + RFC 0007 acceptance and recommendation packet exist + +### 1. Advance Zag at the source +- [ ] `G1-LINUX-ARM64`: upstream prerequisite `target-linux-arm64` is `partial`: zag-poc/VERSIONING.md marks ARM64 Linux experimental and cites qemu-user execution, while unsupported numeric, debug, and call cases remain and no physical ARM64 evidence is recorded. +- [ ] `G1-DARWIN`: upstream prerequisite `target-darwin-macho` is `missing`: zag-poc/VERSIONING.md calls macOS not planned yet and requires a Mach-O backend. +- [ ] `G1-WINDOWS`: upstream prerequisite `target-windows-pe-coff` is `missing`: zag-poc/VERSIONING.md calls Windows not planned and requires a PE/COFF backend. +- [ ] `G1-IOS`: upstream prerequisite `target-ios-arm64` is `missing`: No iOS target or native conformance suite exists at the pinned commit. +- [ ] `G1-ANDROID`: upstream prerequisite `target-android-arm64` is `missing`: No Android target or native conformance suite exists at the pinned commit. +- [ ] `G1-OBJC`: upstream prerequisite `abi-objective-c` is `missing`: No Objective C runtime ABI implementation or native conformance exists at the pinned commit. +- [ ] `G1-COM`: upstream prerequisite `abi-com` is `missing`: No COM ABI implementation or native conformance exists at the pinned commit. +- [ ] `G1-JNI`: upstream prerequisite `abi-jni` is `missing`: No JNI ABI implementation or native conformance exists at the pinned commit. +- [ ] `G1-CALLBACKS`: upstream prerequisite `abi-callbacks` is `partial`: The pinned v2 ABI documents executable qsort evidence for one direct captureless scalar and pointer callback, while captures, returned callbacks, floats, aggregates, ownership, and unload contracts remain unsupported. +- [ ] `G1-AGGREGATES`: upstream prerequisite `abi-aggregates` is `missing`: No general foreign aggregate parameter and return ABI conformance exists at the pinned commit. +- [ ] `G1-FFI-LIFETIMES`: requires downstream implementation and native evidence +- [ ] `G1-RUNTIME-RESOURCES`: requires downstream implementation and native evidence +- [ ] `G1-RESOURCES`: upstream prerequisite `resource-embedding` is `partial`: The exact clean pinned commit defines compiler-owned #embed, source-relative identity, binary and empty resources, structured E0017 failures, foreground-cache byte identity, deterministic x86-64 execution, and qemu-user ARM64 execution through zag-poc/tests/run_resource_embed.sh. The clean tree also reached a byte-identical self-host fixpoint. +- [ ] `G1-DYNAMIC-LOAD`: upstream prerequisite `dynamic-platform-loading` is `partial`: zag-poc/docs/DYNAMIC_SYSTEM_ABI.md proves a narrow x86-64 Linux dynamic ELF import path with scalar outbound calls and one captureless callback; other targets, aggregates, unload, TLS, and general callbacks are unsupported. +- [ ] `G1-CONCURRENCY`: upstream prerequisite `main-loop-and-workers` is `partial`: The pinned v2 concurrency guide documents bounded x86-64 atomics, futex wait and wake, and a direct join-only Linux worker slice; general arguments, detach, TLS, cross-platform workers, and a complete memory model remain unsupported. +- [ ] `G1-PACKAGES`: upstream prerequisite `package-resolution` is `partial`: zag.mod parsing and local dependency validation exist, but the language specification excludes package registry and dependency resolution semantics. +- [ ] `G1-RELOAD`: upstream prerequisite `incremental-and-reload-hooks` is `partial`: The pinned zagd architecture documents a checksummed incremental declaration index and background semantic rechecking, but not an in-process incremental parser or stable state-preserving library reload contract. +- [ ] `G1-SOURCE-FIRST`: ongoing invariant: fix reusable compiler, runtime, ABI, package, concurrency, tooling, and language-ergonomics causes in canonical Zag with an upstream regression before resuming a consumer + +### 3. Text, assets, materials, and motion +- [ ] `G3-UNICODE`: foundation contracts are in place; Unicode normalization, scripts, and locale logic are not yet implemented end to end +- [ ] `G3-OPENTYPE`: headless contracts do not yet include full shaping, fallback, and OpenType asset coverage +- [ ] `G3-EDITING`: text editing, selection, caret, and IME-aware model are not yet implemented +- [ ] `G3-FONTS`: font fallback policy, precision typography, and legibility matrix are not yet implemented +- [ ] `G3-COLOR`: wide-gamut conversion and color-management contracts are not yet implemented +- [ ] `G3-SVG`: secure SVG decode/render contract is not yet implemented +- [ ] `G3-PNG`: full PNG color-profile, malformed, and safety contracts are not yet implemented +- [ ] `G3-SHADOWS`: material shadow primitives and shadow test suite are not yet implemented +- [ ] `G3-LIGHTING`: lighting and depth contracts are not yet implemented +- [ ] `G3-GLASS`: glass material contracts are not yet implemented +- [ ] `G3-MOTION`: advanced motion contract beyond replay is not yet implemented +- [ ] `G3-REDUCED-MOTION`: reduced-motion replacement matrix and assertions are not yet implemented +- [ ] `G3-ASSET-PIPELINE`: asset lifecycle, missing asset behavior, and cleanup contracts are not yet implemented + +### 4. Input, accessibility, Talkback, and tooling +- [ ] `G4-INPUT`: input routing across pointer/keyboard/touch/pen/gamepad host seams is not yet implemented +- [ ] `G4-GESTURES`: gesture arbitration and handoff contracts are not yet implemented +- [ ] `G4-TALKBACK-ACTIONS`: the in-process protocol validates and records events, but no native transport executes the full command set against live applications +- [ ] `G4-TALKBACK-PIXELS`: the in-process dispatcher and ID-derived resolver exist, but no capability-backed native driver executes and records pixel fallback yet +- [ ] `G4-TALKBACK-INSPECT`: tree and timeline data are partial; native layout-reason, screenshot, and complete capability inspection remain unavailable +- [ ] `G4-ACCESSIBILITY`: `linux` capability `accessibility` unavailable: No AT-SPI adapter exists or has assistive technology evidence. +- [ ] `G4-CLI`: CLI host workflow is currently headless-only and lacks promoted-target clean-workflow verification +- [ ] `G4-PREVIEW`: live preview and reload contracts are not yet implemented +- [ ] `G4-INSPECTORS`: inspector surfaces are not yet implemented +- [ ] `G4-GALLERY`: component gallery conformance surface is not yet implemented + +### 5. Polished Linux reference platform +- [ ] `G5-WAYLAND`: `linux` capability `platform_shell` unavailable: A native X11 fallback creates, resizes, presents, synchronizes, and cleans up one window; Wayland and production lifecycle coverage remain unavailable. +- [ ] `G5-X11`: `linux` capability `platform_shell` unavailable: A native X11 fallback creates, resizes, presents, synchronizes, and cleans up one window; Wayland and production lifecycle coverage remain unavailable. +- [ ] `G5-ATSPI`: `linux` capability `accessibility` unavailable: No AT-SPI adapter exists or has assistive technology evidence. +- [ ] `G5-LINUX-CPU`: `linux` capability `cpu_renderer` unavailable: The deterministic CPU oracle covers the current retained display-list subset, including analytic rounded rectangles, and presents byte-identical output through X11; complete text, effects, and operation coverage remain unfinished. +- [ ] `G5-LINUX-GPU`: `linux` capability `gpu_transport` unavailable: No Zagkit Linux GPU transport exists or has device evidence. +- [ ] `G5-LINUX-POLISH`: `linux` capability `packaging` unavailable: No installable Linux artifact or packaging gate exists. +- [ ] `G5-LINUX-FIDELITY`: `linux` capability `cpu_renderer` unavailable: The deterministic CPU oracle covers the current retained display-list subset, including analytic rounded rectangles, and presents byte-identical output through X11; complete text, effects, and operation coverage remain unfinished. +- [ ] `G5-SHOWCASE-CONFORMANCE`: the experimental preview does not yet pass token provenance, semantic symbol/color, elevation, interaction-state, typography-ramp, chart, segmented-control, and navigation-role proof +- [ ] `G5-LINUX-PACKAGE`: `linux` capability `packaging` unavailable: No installable Linux artifact or packaging gate exists. + +### 6. Complete PrismStudio overhaul +- [ ] `G6-INVENTORY`: PrismStudio migration work requires replacement of visible shell and inventory mapping +- [ ] `G6-DESIGN`: PrismStudio visual direction must be selected and accepted before migration +- [ ] `G6-SHELL`: PrismStudio shell replacement is not implemented in this repository +- [ ] `G6-WORKFLOWS`: PrismStudio workflows have not yet been migrated to Zagkit-native equivalents +- [ ] `G6-VIEWPORT`: PrismStudio viewport chrome and interactions remain unmigrated +- [ ] `G6-DENSE-UI`: PrismStudio dense UI surfaces remain unmigrated +- [ ] `G6-MATERIALS`: Materials and visual tokens for PrismStudio have not been migrated +- [ ] `G6-ASSETS`: PrismStudio production asset migration remains incomplete +- [ ] `G6-AUTOMATION`: PrismStudio actions must expose stable IDs through a native UI migration +- [ ] `G6-ACCESSIBILITY`: PrismStudio accessibility polish is blocked on full migration +- [ ] `G6-SCREENSHOTS`: PrismStudio native screenshot comparison cannot run before full UI migration +- [ ] `G6-PERFORMANCE`: PrismStudio performance gates depend on migrated native UI and runtime +- [ ] `G6-POLISH`: PrismStudio polish requires full migration and defect closure + +### 7. Desktop, mobile, and shared 1.0 +- [ ] `G7-MACOS`: depends on completed upstream targets, linux parity, and migration evidence +- [ ] `G7-WINDOWS`: depends on completed upstream targets, linux parity, and migration evidence +- [ ] `G7-IOS`: depends on completed upstream targets, linux parity, and migration evidence +- [ ] `G7-ANDROID`: depends on completed upstream targets, linux parity, and migration evidence +- [ ] `G7-MOBILE-REFERENCE`: depends on native mobile runtime, text/IME, and component migration +- [ ] `G7-COMPONENT-PARITY`: depends on component suite migration across all five targets +- [ ] `G7-TEXT-PARITY`: depends on Unicode, font, IME, and text rendering completion +- [ ] `G7-RECOVERY`: depends on recovery/lifecycle evidence across all five platforms +- [ ] `G7-PERFORMANCE`: depends on 120Hz/idle/stall/recovery evidence on reference hardware +- [ ] `G7-PACKAGING`: depends on install/update/uninstall coverage on all supported platforms +- [ ] `G7-ONE-POINT-ZERO`: depends on every remaining milestone and unexpired waivers + +## Immediate next actions + +- Advance upstream prerequisites in `/home/micah/Desktop/Sylorlabs/zag` until no required G1 entries are `missing`/`partial`. +- Complete RFC 0007 full-direction acceptance after full visual matrix evidence is generated. +- Implement Linux shell/AT-SPI and capability-backed backends only after capability blockers are reduced. +- Remove PrismStudio consumer workarounds only after their reusable causes have upstream Zag regressions and fixed compiler revisions. +- Continue the inventory-driven PrismStudio overhaul in the canonical repository, with native tests and screenshot evidence for every promoted surface. diff --git a/tools/emit-agent-checklist.sh b/tools/emit-agent-checklist.sh index 459ab99..9d5e46d 100755 --- a/tools/emit-agent-checklist.sh +++ b/tools/emit-agent-checklist.sh @@ -98,6 +98,9 @@ goal_to_fallback_reason = { "G5-SHOWCASE-CONFORMANCE": "the experimental preview does not yet pass token provenance, semantic symbol/color, elevation, interaction-state, typography-ramp, chart, segmented-control, and navigation-role proof", "G4-INPUT": "input routing across pointer/keyboard/touch/pen/gamepad host seams is not yet implemented", "G4-GESTURES": "gesture arbitration and handoff contracts are not yet implemented", + "G4-TALKBACK-ACTIONS": "the in-process protocol validates and records events, but no native transport executes the full command set against live applications", + "G4-TALKBACK-PIXELS": "the in-process dispatcher and ID-derived resolver exist, but no capability-backed native driver executes and records pixel fallback yet", + "G4-TALKBACK-INSPECT": "tree and timeline data are partial; native layout-reason, screenshot, and complete capability inspection remain unavailable", "G4-CLI": "CLI host workflow is currently headless-only and lacks promoted-target clean-workflow verification", "G4-PREVIEW": "live preview and reload contracts are not yet implemented", "G4-INSPECTORS": "inspector surfaces are not yet implemented", From 743bd939a0da8f9da9325f0a4eb3610fba247d04 Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sun, 9 Aug 2026 09:13:55 -0700 Subject: [PATCH 102/103] Tighten showcase system proof --- docs/design/showcase-conformance.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/design/showcase-conformance.md b/docs/design/showcase-conformance.md index c8c629c..6d5a8b7 100644 --- a/docs/design/showcase-conformance.md +++ b/docs/design/showcase-conformance.md @@ -17,6 +17,10 @@ but it must be labeled experimental and cannot promote the visual system. accepted as token proof. - Component-local exceptions are named, reviewable, and never silently become a second token scale. +- The showcase includes a live token-catalog/inspector route where reviewers + can select a rendered node and see its color, type, spacing, radius, + elevation, material, and interaction-state token IDs. Source-only token + tables do not count as visible showcase proof. ### Semantic symbols and color @@ -35,6 +39,9 @@ but it must be labeled experimental and cannot promote the visual system. navigation system. - Typography demonstrates display, title, heading, body, label, caption, and code roles with tested line-height, truncation, locale, and large-text rules. +- A reusable component gallery, typography route, and inspector route are + independently navigable native compositions. A single dashboard containing + one instance of each shape does not count as proof that the system scales. ### Interaction states @@ -45,6 +52,9 @@ but it must be labeled experimental and cannot promote the visual system. transparency, reduced motion, grayscale, and color-vision simulations. - Hover is supplementary. Keyboard, touch, pen, gamepad, accessibility action, and Zagkit Talkback reach the same action and state truth. +- State fixtures identify their state in visible text and semantics, so color + alone is never the evidence that hover, focus, selection, loading, disabled, + or error styling exists. ### Data visualization @@ -60,6 +70,10 @@ but it must be labeled experimental and cannot promote the visual system. focused states, arrow-key behavior, disabled behavior, and tab semantics. - Navigation exposes one canonical active location. Contextual inspectors and system status are labeled and styled as their actual roles. +- A read-only status rail owns no navigation actions, focus stops, selection, + or pointer hit targets. Its category/status marks are either documented + semantic symbols with redundant text or explicitly decorative; arbitrary + colored-dot filler is a conformance failure. - Every enabled navigation item activates its named destination: the selected label, main landmark, visible heading, semantics, actions, and Talkback tree must describe the same content. An item whose destination is not implemented From b46d00f5cfacc97c54147fd0d8fd34aa7977ac7a Mon Sep 17 00:00:00 2001 From: Micah Cooley Date: Sat, 22 Aug 2026 16:59:52 -0700 Subject: [PATCH 103/103] Add Surface, SegmentedControl, ScrollViewport, and TypographySpecimen milestones Introduce three new Milestone-2 components (surface, segmented-control, scroll-viewport) with full contract tests, plus the TypographySpecimen showcase and Linux preview composition. Expand the design system with material tiers, semantic tokens, and the A2 glass-clarity direction, and refresh Talkback/semantics, hit testing, X11 events, and the showcase conformance tooling. Ignore the .zagd.status runtime artifact. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitignore | 1 + GOAL.md | 2 +- README.md | 6 + contracts/components.json | 4 +- contracts/talkback-protocol.json | 18 +- docs/architecture/README.md | 7 + docs/architecture/canvas.md | 7 +- docs/architecture/linux-shell.md | 30 + docs/automation/talkback.md | 26 +- docs/components/button.md | 38 +- docs/components/navigation-item.md | 11 +- docs/components/performance-chart.md | 205 +- docs/components/scroll-viewport.md | 179 ++ docs/components/segmented-control.md | 204 ++ docs/components/surface.md | 181 ++ docs/design/material-tiers.md | 98 + docs/design/semantic-tokens.md | 56 + docs/design/showcase-conformance.md | 25 +- docs/design/visual-direction-tokens/README.md | 4 + .../direction-a2-glass-clarity.json | 172 ++ docs/evidence/goal-progress-live.json | 10 +- docs/evidence/goal-progress-live.md | 20 +- docs/evidence/performance-chart-2026-08-09.md | 118 +- docs/rfcs/0003-text-semantics-and-input.md | 3 + docs/showcase/component-state-gallery.md | 213 ++ docs/showcase/token-inspector.md | 36 + docs/showcase/typography-specimen.md | 230 ++ error-debugging-config.json | 189 ++ examples/linux_preview.zag | 634 +++- src/automation/talkback.zag | 104 + src/components/button.zag | 40 +- src/components/navigation_item.zag | 14 +- src/components/performance_chart.zag | 29 +- src/components/scroll_viewport.zag | 1784 ++++++++++++ src/components/segmented_control.zag | 1164 ++++++++ src/components/surface.zag | 963 ++++++ src/design/tokens.zag | 141 +- src/input/hit_test.zag | 4 + src/platform/linux/x11.zag | 48 +- src/semantics/semantics.zag | 11 + src/showcase/button_gallery.zag | 10 +- src/showcase/component_state_gallery.zag | 2572 +++++++++++++++++ src/showcase/linux_preview_composition.zag | 2447 ++++++++++++++++ src/showcase/linux_preview_layout.zag | 30 + src/showcase/linux_preview_model.zag | 13 +- src/showcase/linux_preview_scene.zag | 27 +- src/showcase/typography_specimen.zag | 1345 +++++++++ tests/component_state_gallery_contract.zag | 1069 +++++++ tests/design_tokens_contract.zag | 61 +- tests/linux_preview_composition_contract.zag | 1149 ++++++++ tests/linux_preview_semantics_contract.zag | 14 +- tests/linux_x11_event_contract.zag | 157 + tests/navigation_item_contract.zag | 2 +- tests/negative/canvas_double_free.zag | 12 + tests/negative/surface_return_lifetime.zag | 30 + tests/performance_chart_contract.zag | 4 +- tests/scroll_viewport_contract.zag | 664 +++++ tests/segmented_control_contract.zag | 874 ++++++ tests/surface_contract.zag | 671 +++++ tests/typography_specimen_contract.zag | 592 ++++ tools/check-showcase-conformance.sh | 139 + tools/test-component-state-gallery.sh | 32 + tools/test-linux-interaction.sh | 836 ++++++ tools/test-linux-preview-composition.sh | 41 + tools/test-scroll-viewport.sh | 18 + tools/test-segmented-control.sh | 18 + tools/test-surface.sh | 41 + tools/test-typography-specimen.sh | 44 + zag.mod | 2 +- 69 files changed, 19645 insertions(+), 298 deletions(-) create mode 100644 docs/components/scroll-viewport.md create mode 100644 docs/components/segmented-control.md create mode 100644 docs/components/surface.md create mode 100644 docs/design/material-tiers.md create mode 100644 docs/design/semantic-tokens.md create mode 100644 docs/design/visual-direction-tokens/direction-a2-glass-clarity.json create mode 100644 docs/showcase/component-state-gallery.md create mode 100644 docs/showcase/token-inspector.md create mode 100644 docs/showcase/typography-specimen.md create mode 100644 error-debugging-config.json create mode 100644 src/components/scroll_viewport.zag create mode 100644 src/components/segmented_control.zag create mode 100644 src/components/surface.zag create mode 100644 src/showcase/component_state_gallery.zag create mode 100644 src/showcase/linux_preview_composition.zag create mode 100644 src/showcase/typography_specimen.zag create mode 100644 tests/component_state_gallery_contract.zag create mode 100644 tests/linux_preview_composition_contract.zag create mode 100644 tests/linux_x11_event_contract.zag create mode 100644 tests/negative/canvas_double_free.zag create mode 100644 tests/negative/surface_return_lifetime.zag create mode 100644 tests/scroll_viewport_contract.zag create mode 100644 tests/segmented_control_contract.zag create mode 100644 tests/surface_contract.zag create mode 100644 tests/typography_specimen_contract.zag create mode 100755 tools/check-showcase-conformance.sh create mode 100755 tools/test-component-state-gallery.sh create mode 100755 tools/test-linux-interaction.sh create mode 100755 tools/test-linux-preview-composition.sh create mode 100755 tools/test-scroll-viewport.sh create mode 100755 tools/test-segmented-control.sh create mode 100755 tools/test-surface.sh create mode 100755 tools/test-typography-specimen.sh diff --git a/.gitignore b/.gitignore index 7993d4a..9870c8a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ zag-out/ .zag-cache/ .zagd.lock .zagd.semantic-ready +.zagd.status .zagkit/ # Contract and benchmark output diff --git a/GOAL.md b/GOAL.md index 5661053..296642b 100644 --- a/GOAL.md +++ b/GOAL.md @@ -106,7 +106,7 @@ more precision is needed and preserve its original ID as the parent. ## 6. Complete PrismStudio overhaul -- [ ] `G6-INVENTORY` Inventory every existing PrismStudio workflow, command, screen, and safety boundary. — Exit: migration map has no unowned visible or keyboard-accessible behavior +- [x] `G6-INVENTORY` Inventory every existing PrismStudio workflow, command, screen, and safety boundary. — Evidence: PrismStudio commit `5e47883`, [migration inventory](../PrismStudio/docs/ZAGKIT_MIGRATION_INVENTORY.md), and [static inventory gate](../PrismStudio/tools/zagkit_migration_inventory_audit.sh) - [ ] `G6-DESIGN` Select and approve one of three PrismStudio visual directions using representative CAD states. — Exit: maintainers choose a reviewed target with light, dark, contrast, large-text, and reduced-effects variants - [ ] `G6-SHELL` Replace the entire visible PrismStudio shell with Zagkit. — Exit: no legacy app-owned widget, layout, or styling path remains in the supported Linux product - [ ] `G6-WORKFLOWS` Preserve and modernize all CAD workflows and direct manipulation. — Exit: canonical task scripts pass by keyboard, pointer, and Zagkit Talkback IDs diff --git a/README.md b/README.md index 8f0080c..cdf5ebc 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,12 @@ X11 fallback. The window presents Zagkit's deterministic CPU surface through the public Xlib transport, handles close, expose, and live resize events, and prints the backend selected by `.auto`. It is experimental and does not claim Wayland, IME, AT-SPI, GPU, packaging, or Linux-polish completion. +The expanded reference opens at 1360 by 900 logical pixels. The launcher +requires distinct light, regular, and bold font files for the native +Typography route; it resolves Fira Sans through Fontconfig when the three +`ZAGKIT_FONT_LIGHT_FILE`, `ZAGKIT_FONT_FILE`, and +`ZAGKIT_FONT_BOLD_FILE` variables are not supplied, and fails closed rather +than aliasing a weight. Use `./zagkit run --linux-preview --output ` for the bounded native create/present/sync/capture/cleanup path used by conformance automation. See the [Linux shell contract](docs/architecture/linux-shell.md). diff --git a/contracts/components.json b/contracts/components.json index 1dd9fcf..efa158a 100644 --- a/contracts/components.json +++ b/contracts/components.json @@ -27,11 +27,13 @@ {"id":"svg","family":"content","name":"Svg","milestone":2,"status":"planned","semantic_roles":["image","none"],"inputs":[],"adaptive":["density","contrast","direction","color_space"]}, {"id":"png-image","family":"content","name":"PngImage","milestone":2,"status":"planned","semantic_roles":["image"],"inputs":[],"adaptive":["density","contrast","color_space"]}, {"id":"material","family":"effects","name":"Material","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["contrast","color_space","reduced_transparency"]}, + {"id":"surface","family":"effects","name":"Surface","milestone":2,"status":"implementing","semantic_roles":["group","button"],"inputs":["pointer","keyboard","touch","accessibility_action","zagkit_talkback"],"adaptive":["density","contrast","text_scale","reduced_transparency"],"source":"src/components/surface.zag","evidence":"tests/surface_contract.zag","documentation":"docs/components/surface.md"}, {"id":"glass","family":"effects","name":"Glass","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["contrast","reduced_motion","reduced_transparency"]}, {"id":"shadow","family":"effects","name":"Shadow","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["density","contrast","color_space"]}, {"id":"light","family":"effects","name":"Light","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["contrast","color_space","reduced_transparency"]}, {"id":"divider","family":"content","name":"Divider","milestone":2,"status":"planned","semantic_roles":["separator"],"inputs":[],"adaptive":["density","contrast","direction"]}, {"id":"button","family":"actions","name":"Button","milestone":2,"status":"implementing","semantic_roles":["button"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","contrast","reduced_motion"],"source":"src/components/button.zag","evidence":"tests/button_contract.zag","documentation":"docs/components/button.md","gallery_source":"src/showcase/button_gallery.zag","gallery_evidence":"tests/button_gallery_contract.zag","gallery_documentation":"docs/components/button-gallery.md"}, + {"id":"segmented-control","family":"actions","name":"SegmentedControl","milestone":2,"status":"implementing","semantic_roles":["tablist","tab","radiogroup","radio"],"inputs":["pointer","keyboard","touch","gamepad","accessibility_action","zagkit_talkback"],"adaptive":["density","platform","contrast","direction","text_scale"],"source":"src/components/segmented_control.zag","evidence":"tests/segmented_control_contract.zag","documentation":"docs/components/segmented-control.md"}, {"id":"toggle","family":"actions","name":"Toggle","milestone":2,"status":"planned","semantic_roles":["switch"],"inputs":["pointer","keyboard","touch","gamepad"],"adaptive":["density","platform","contrast","reduced_motion"]}, {"id":"checkbox","family":"actions","name":"Checkbox","milestone":2,"status":"planned","semantic_roles":["checkbox"],"inputs":["pointer","keyboard","touch"],"adaptive":["density","platform","contrast"]}, {"id":"radio","family":"actions","name":"RadioGroup","milestone":2,"status":"planned","semantic_roles":["radio","radiogroup"],"inputs":["pointer","keyboard","touch"],"adaptive":["density","platform","direction"]}, @@ -66,7 +68,7 @@ {"id":"stack","family":"layout","name":"Stack","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["density","direction","text_scale"]}, {"id":"flex","family":"layout","name":"Flex","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["density","breakpoint","direction","text_scale"]}, {"id":"overlay","family":"layout","name":"Overlay","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["direction","text_scale"]}, - {"id":"scroll","family":"layout","name":"Scroll","milestone":2,"status":"planned","semantic_roles":["group"],"inputs":["pointer","keyboard","touch","pen","wheel","gamepad"],"adaptive":["density","platform","direction","reduced_motion"]}, + {"id":"scroll","family":"layout","name":"ScrollViewport","milestone":2,"status":"implementing","semantic_roles":["group","slider"],"inputs":["pointer","keyboard","touch","pen","wheel","gamepad","accessibility_action","zagkit_talkback"],"adaptive":["density","platform","direction","reduced_motion"],"source":"src/components/scroll_viewport.zag","evidence":"tests/scroll_viewport_contract.zag","documentation":"docs/components/scroll-viewport.md"}, {"id":"safe-area","family":"layout","name":"SafeArea","milestone":2,"status":"planned","semantic_roles":["none"],"inputs":[],"adaptive":["platform","orientation"]}, {"id":"canvas","family":"advanced","name":"Canvas","milestone":2,"status":"implementing","semantic_roles":["group"],"inputs":["pointer","keyboard","touch","pen"],"adaptive":["density","contrast","direction"],"source":"src/components/canvas.zag","evidence":"tests/canvas_contract.zag","documentation":"docs/architecture/canvas.md"}, {"id":"performance-chart","family":"advanced","name":"PerformanceChart","milestone":2,"status":"implementing","semantic_roles":["group","text","button","table","row","cell"],"inputs":["pointer","keyboard","touch"],"adaptive":["density","contrast","text_scale"],"source":"src/components/performance_chart.zag","evidence":"tests/performance_chart_contract.zag","documentation":"docs/components/performance-chart.md"}, diff --git a/contracts/talkback-protocol.json b/contracts/talkback-protocol.json index 91afa9e..79c83c7 100644 --- a/contracts/talkback-protocol.json +++ b/contracts/talkback-protocol.json @@ -1,6 +1,6 @@ { "name": "Zagkit Talkback", - "version": "0.2.0-experimental", + "version": "0.3.0-experimental", "android_assistive_technology": "Android TalkBack", "transport": "not-yet-available", "targeting": { @@ -53,7 +53,10 @@ "pixel-x", "pixel-y", "scale-numerator", - "scale-denominator" + "scale-denominator", + "scroll-axis", + "scroll-delta-x", + "scroll-delta-y" ], "response_fields": [ "request-id", @@ -81,15 +84,23 @@ "state-flags", "evidence-hash" ], + "state_flag_bits": { + "disabled": 1, + "selected": 2, + "hidden": 4, + "focused": 8 + }, "event_contract": { "ordered": true, "records_accepted_and_rejected": true, "retains_target_kind": true, - "retains_pixel_scale": true + "retains_pixel_scale": true, + "retains_scroll_axis_and_delta": true }, "current_runtime_truth": { "semantic-query": "experimental", "id-actions": "experimental-event-emission", + "scroll-parameters": "experimental-validated-event-emission", "key-actions": "experimental-event-emission", "drag-actions": "experimental-event-emission", "gesture-actions": "experimental-event-emission", @@ -97,6 +108,7 @@ "assert-actions": "experimental-event-emission", "snapshot": "experimental-event-emission", "pixel-fallback": "experimental-disabled-by-default", + "id-derived-pixel-fallback": "experimental-in-process-resolver", "screenshots": "unavailable", "replay": "experimental-event-emission", "native-transport": "unavailable" diff --git a/docs/architecture/README.md b/docs/architecture/README.md index afeb6bf..3b2cbd6 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -28,7 +28,14 @@ Normative decisions: Detailed experimental contracts: +- [semantic token role boundaries and inspector evidence](../design/semantic-tokens.md) +- [experimental native token-inspector boundary](../showcase/token-inspector.md) - [shared Button identity, state, measurement, semantics, and Talkback](../components/button.md) +- [Surface hierarchy tiers and actionable card state](../components/surface.md) +- [single-selection SegmentedControl state and focus truth](../components/segmented-control.md) +- [ScrollViewport clipping, axis ranges, stable-ID reveal, and atomic hosting](../components/scroll-viewport.md) +- [accessible PerformanceChart anatomy and semantic table](../components/performance-chart.md) +- [hostable thirteen-row typography specimen](../showcase/typography-specimen.md) - [retained Canvas immediate drawing escape hatch](canvas.md) - [intrinsic measurement and invalidation reasons](measurement.md) - [Flex placement and adaptive spacing](flex.md) diff --git a/docs/architecture/canvas.md b/docs/architecture/canvas.md index 964df6e..cbfa71c 100644 --- a/docs/architecture/canvas.md +++ b/docs/architecture/canvas.md @@ -65,12 +65,17 @@ if (canvas_seal(&canvas) == CanvasError.none) { _ = canvas_contribute_hit(&canvas, &hit_tree); } -_ = canvas_free(&canvas); +canvas_free(&canvas); ``` Production code must inspect every returned `CanvasError`. `last_error` plus `display_error`, `resource_error`, `semantics_error`, `hit_error`, `error_index`, and `error_resource_id` preserve the exact failing boundary. +`canvas_free` is different: it is a `void` destructor whose parameter is +`@consumes *Canvas`. The compiler rejects a repeated release or later use of +the consumed frame; cleanup is not an ordinary fallible mutation. +[`tests/negative/canvas_double_free.zag`](../../tests/negative/canvas_double_free.zag) +keeps that affine boundary executable. ## Bounds, clip, and transform diff --git a/docs/architecture/linux-shell.md b/docs/architecture/linux-shell.md index 1cdac5d..2b6212f 100644 --- a/docs/architecture/linux-shell.md +++ b/docs/architecture/linux-shell.md @@ -23,6 +23,36 @@ Run the bounded native conformance capture with: ./tools/test-linux-preview.sh ``` +The reference client area is 1360 by 900 logical pixels so the expanded +placement can host the chart, labelled status evidence, and the measured +Typography specimen without clipping. The shell requires three distinct +Zag-owned OpenType faces: `ZAGKIT_FONT_LIGHT_FILE`, `ZAGKIT_FONT_FILE`, and +`ZAGKIT_FONT_BOLD_FILE`. The launcher resolves Fira Sans weights with +Fontconfig when they are unset, validates that the files exist and differ, and +otherwise stops before opening a window. + +The deeper native interaction gate is: + +```sh +./tools/test-linux-interaction.sh --evidence-dir +``` + +It associates one exact-title window with the launched PID, resolves controls +from stable Talkback IDs to recorded scale-aware pixel fallbacks, verifies +disabled navigation, token-inspector state, segmented pointer and keyboard +state, the real Typography route, unsupported-size and compact resize states, +idle CPU work, native close, and cleanup. Screenshots from this runner remain +review evidence, not a substitute for the semantic and lifecycle assertions. + +Primary navigation and the token-inspector Button consume one retained +`LinuxPreviewInteractionState`. X11 pointer motion/down/up drives real hover +and pressed styles; pointer focus is semantic without a keyboard ring. Tab and +Shift-Tab traverse the semantic `focus_order` values, including the segmented +control's single roving stop, while Enter and Space activate the focused +destination. Resize clears a focus owner that becomes unavailable rather than +leaving invisible focus behind. Rendering, semantics, hit testing, trace +output, and Zagkit Talkback therefore observe one NodeKey-based state truth. + `PlatformCapabilities` records `.auto` selection. When a Wayland endpoint is visible but the Wayland shell is unavailable, choosing X11 records a fallback event and reason. X11 window and CPU presentation report `experimental`; GPU, diff --git a/docs/automation/talkback.md b/docs/automation/talkback.md index c18c273..9cfb744 100644 --- a/docs/automation/talkback.md +++ b/docs/automation/talkback.md @@ -18,13 +18,24 @@ coordinates and a positive rational display scale. Zagkit converts them to its pixel attempt remains labelled `pixel`; it can never be reported as an ID action. +When an external platform driver can inject only pixels, the in-process +`talkback_pixel_fallback_for_id` resolver derives one target center from the +current semantic ID, exact bounds, semantic revision, command, and rational +scale. Its result records the source ID, disabled/action availability, logical +bounds, physical center, scale, and evidence hash. That source is provenance, +not a change of target kind: the eventual injected action must still be logged +as `pixel`. Hidden, unbounded, out-of-viewport, and invalid-scale targets fail +before coordinates are produced. This resolver does not claim that the still +missing native Talkback transport exists. + ## Requests and evidence The machine-readable field, command, status, and capability vocabulary lives in [the protocol contract](../../contracts/talkback-protocol.json). Request IDs must be positive. Timeouts must be between 1 and 300000 milliseconds. Every dispatch receives one monotonic event sequence and records the command, target -kind, target ID, semantic revision, status, coordinates, and scale. +kind, target ID, semantic revision, status, coordinates, scale, and explicit +scroll axis and signed logical delta when applicable. The in-process slice resolves semantic discovery and queries and validates click, type, focus, and scroll before emitting them into the ordered event @@ -37,6 +48,17 @@ fixed-point bounds, collection counts and coordinates, tree level and expansion state, owned-text lengths, state flags, and a deterministic evidence hash over the complete semantic node. This makes geometry and semantic-state changes observable even before the native transport gains structured text payloads. +Protocol minor version 3 makes scroll payloads mandatory: `horizontal` +requires a nonzero X delta and zero Y delta, `vertical` requires the inverse, +and `both` accepts either or both nonzero deltas. Deltas use Zagkit's signed +26.6 logical units and are retained in event evidence. This validates intent; +it does not claim the still-unavailable native transport applied the scroll. + +State flags are a stable bit set: `disabled = 1`, `selected = 2`, `hidden = 4`, +and `focused = 8`. `focused` reports actual retained input focus; it is not a +proxy for focus order, the roving tab stop, a focus-ring specimen, or keyboard +modality. One semantics tree may expose at most one focused node, and that node +must be enabled, visible, ordered, and support the semantic focus action. Capability reports and timeline counts are available. Pixel fallback is fail-closed unless advertised, and screenshots remain unavailable in this slice. Advertising a command in the protocol vocabulary does not claim its runtime @@ -45,7 +67,7 @@ capability. ## Planned agent contract The complete control plane will add app and window discovery, structured query -results, text payloads, keyboard input, scrolling parameters, paths and +results, text payloads, keyboard input, paths and velocity for drag and gesture, waits and assertions, screenshots, frame timelines, capability records, snapshots, and deterministic replay. Failed actions will retain candidates, geometry, semantics, backend truth, and timeout diff --git a/docs/components/button.md b/docs/components/button.md index e68d6c9..5f31e0d 100644 --- a/docs/components/button.md +++ b/docs/components/button.md @@ -19,7 +19,7 @@ Call `button_emit` with the frame's mutable `DisplayList`, `HitTree`, and - the shared stable ID and exact bounds; - caller-owned text content bounds resolved from Flex spacing; - every semantic color and elevation token selected for the state; -- effective enabled and content visibility truth; +- effective enabled, content visibility, actual focus, and focus-ring truth; - display operation, hit-node, and semantic-node locations; and - a deterministic evidence hash over identity, geometry, state, and tokens. @@ -55,14 +55,34 @@ loading, and error. Styling resolves with documented precedence: 4. loading makes actions unavailable and substitutes a progress mark; and 5. disabled overrides material, text, focus, hit, and action availability. -Focus is a separate visible ring and never relies on fill color. Selection is -also exposed through `SemanticsNode.selected`; loading and error expose text -values. Color is therefore supplementary state evidence. - -`button_reduce` accepts pointer, focus, selection, availability, loading, and -error events. It emits `activate` only for an enabled press followed by an -inside release. It never mutates application state: state still flows down and -the emitted action flows up. +Actual focus and focus-ring visibility are separate state. Pointer focus sets +`focused = 1` and `focus_visible = 0`; keyboard focus sets both. Only +`focus_visible` paints the retained ring, while `SemanticsNode.focused` and +Talkback always report actual focus. A ring without actual focus is invalid. +Selection is exposed through `SemanticsNode.selected`; loading and error expose +text values. Color is therefore supplementary state evidence. + +`button_reduce` accepts pointer, explicit pointer-focus, explicit +keyboard-focus, focus-loss, selection, availability, loading, and error events. +It emits `activate` only for an enabled press followed by an inside release. +Loading and disabling relinquish focus before actions become unavailable. It +never mutates application state: state still flows down and the emitted action +flows up. + +## Motion + +Button is a pure per-frame emitter: it has no clock and cannot own a running +animation. Instead, `button_motion_token(interaction)` names which semantic +spring (`motion.snappy`, `motion.gentle`, or `motion.expressive`, resolved by +`semantic_motion_spring` in `src/design/tokens.zag`) a host should drive +toward the button's new `ButtonResolvedStyle` whenever `ButtonInteraction` +changes. A host keeps one `MotionTrack` per button `NodeKey` in its +`MotionScheduler`, retargets it on state change, and blends the interpolated +value into `fill_alpha` and the elevation shadow offset before calling +`button_emit` each frame. Because the underlying model is a physical spring +rather than a fixed-duration curve, rapid re-presses or hover flicker +continue from the current velocity instead of restarting or snapping, which +is required for `reduced_motion` to substitute a plain snap-to-end safely. ## Variants and tokens diff --git a/docs/components/navigation-item.md b/docs/components/navigation-item.md index ec9d585..3a29e30 100644 --- a/docs/components/navigation-item.md +++ b/docs/components/navigation-item.md @@ -26,16 +26,19 @@ radius, elevation, and Flex spacing tokens. State is not encoded by color alone: -- focus emits a retained outer focus ring; +- actual focus is semantic state, while keyboard-visible focus emits the + retained outer ring; - selection emits a persistent leading marker and `selected` semantic truth; - loading emits a two-part progress rail, exposes the value `Loading`, and makes actions unavailable while remaining discoverable; and -- error emits a shaped exclamation badge and exposes the value `Error`. +- error emits a trailing state rail sized by Flex `tiny` and `xlarge` + spacing tokens and exposes the value `Error`. Disabled items remain queryable but expose no activate, select, or focus action and are removed from pointer and focus routing. Enabled items expose all three -actions. The exact focus order, set size, and one-based position in set are -copied into the parallel semantic tree. +actions. Actual focus, exact focus order, set size, and one-based position in +set are copied into the parallel semantic tree. Pointer focus does not invent a +keyboard ring, and visual focus is never the only focus evidence. ## Placement and Text composition diff --git a/docs/components/performance-chart.md b/docs/components/performance-chart.md index 5a0a8f9..3c22815 100644 --- a/docs/components/performance-chart.md +++ b/docs/components/performance-chart.md @@ -1,9 +1,9 @@ # Experimental performance chart `PerformanceChart` is one bounded line/scatter component for frame-performance -evidence. It proves chart anatomy, semantic equivalence, token routing, focus -state, and stable-ID automation. It is not a general chart suite and is not a -production visual-polish claim. +evidence. It proves chart anatomy, semantic equivalence, token routing, +opt-in focus state, and stable-ID automation. It is not a general chart suite +and is not a production visual-polish claim. ## Scope and input contract @@ -15,9 +15,12 @@ The component accepts one `PerformanceChartSpec` with: - independently named x and y axes with explicit units; - bounded integer domains, two to twelve ticks per axis, and one caller-owned label for every tick; -- named baseline and deadline values inside the y domain; and -- one to eight named series, each containing two to sixty-four aligned, - strictly increasing samples. +- named baseline and deadline values inside the y domain, with display-ready + labels that include the relevant value and unit; +- one to three named series, each containing two to sixty-four aligned, + strictly increasing samples; and +- an explicit content state plus an explicit legend interaction policy. The + compatibility constructor sets `ready` and `read_only`. Every sample carries its numeric x/y coordinates and its already-formatted accessible x/y strings. Series in one chart must have the same sample count and @@ -26,10 +29,68 @@ instead of asking assistive technology to reverse-engineer pixels. Names, descriptions, units, tick labels, and cell strings must be valid, NUL-free UTF-8 within the component bounds. Series IDs must be positive and -unique. Coordinates are bounded to plus or minus one million before fixed-point -mapping, preventing unchecked multiplication. Duplicate IDs, malformed text, -misaligned samples, out-of-range references, excessive series, and malformed -state fail before display, hit, or semantic mutation. +unique. Each chart may use the `reference`, `comparison`, and `diagnostic` +series role at most once. Those roles own their color and marker shape; callers +cannot assign an arbitrary palette value or repeat a role to create unexplained +colored dots. New code constructs series with +`performance_chart_series_role`; the earlier color-token constructor remains a +validation-preserving compatibility adapter. Coordinates are bounded to plus +or minus one million before fixed-point mapping, preventing unchecked +multiplication. Duplicate IDs or series roles, role/color mismatches, +malformed text, misaligned samples, +out-of-range references, excessive series, and malformed state fail before +display, hit, or semantic mutation. + +## Content states + +`PerformanceChartContentState` is a closed contract: + +| State | Data contract | Visible anatomy | Semantic truth | +|---|---|---|---| +| `ready` | one to three validated series | axes, references, marks, legend | exact legend plus full data table | +| `loading` | zero series and zero axis ticks | status panel, accent indicator, indeterminate track | `progress`, status value, polite live region | +| `empty` | zero series; axes off unless explicitly preserved | status panel and axis-colored indicator | named zero-data value, polite live region | +| `error` | zero series and zero axis ticks | status panel and error-token indicator | named failure value, assertive live region | + +Loading, empty, and error require non-empty `status_name` and `status_value` +strings. They never accept caller-supplied series, never synthesize points, and +publish zero table rows and columns. They contribute no legend group, series +semantics, series hit targets, focus order, or actions, regardless of the +stored legend policy. + +`preserve_empty_axes` is the only non-ready axis exception. It is valid only +for `empty`, must be explicitly set to `1`, and requires the same bounded axis +domains, names, units, tick counts, and tick labels as a ready chart. It draws +and publishes axes and ticks, but deliberately omits baseline/deadline +references because there are no samples to compare. Loading, error, and +ordinary empty states require zero tick counts and empty tick-label lists, so +stale axes cannot leak through accidentally. + +Every non-ready state uses a retained `PerformanceChartStatusArtifact` as the +single geometry and token authority for its panel, indicator, name, value, and +optional loading track. The two visible status strings are owned text slots; +the status semantic node redundantly carries the same name/value and exact +bounds. Loading uses `color.accent`, empty uses `color.chart.axis`, and error +uses `color.status.error`; all supporting surfaces and text also resolve +semantic tokens rather than local colors. + +## Legend interaction policy + +Legend interaction is fail-closed. The default `read_only` policy describes +the series as a semantic `list` of `list_item` nodes. Every item retains its +name, description, `Available` / `Selected` / `Unavailable` value, selected +state, disabled state, set size, position, and exact bounds. It has zero focus +order and zero actions, and the legend contributes no hit nodes. The chart's +Canvas root remains the single chart hit node. `focused` and `hovered` series +input is rejected under this policy because the component has no interaction +path that could truthfully produce either state. + +`actionable` is an explicit opt-in. It retains the button semantics, per-series +hit nodes, focus order, focus action, activation action, hover treatment, and +focus treatment. This mode does not install an application reducer: the host +must route pointer, keyboard, accessibility, and Talkback activation to real +series-selection state. Choosing `actionable` without that routing is a host +integration defect, not a chart capability. ## Visual anatomy @@ -39,9 +100,40 @@ The retained Canvas emits: - independently inspectable grid and axis paints; - ticks on both axes; - a named baseline reference and a distinct semantic-error deadline reference; -- one filled line ribbon plus one anti-aliased rounded scatter mark per sample; +- one filled line ribbon plus one role-shaped mark per sample; - named series swatches arranged with Flex; and -- separate selected, focused, available, and disabled legend treatments. +- separate default, hovered, selected, focused, and disabled legend treatments. + +That ready anatomy is state-specific. Non-ready states retain the same raised +outer chart and inset content surface, then replace data marks with the +status artifact described above. A loading track is an availability cue, not +a fabricated performance value; it has no numeric range or sample semantics. + +Series styling is a semantic mapping, not decoration: + +| Series role | Color role | Non-color marker | +|---|---|---| +| `reference` | `color.chart.series.primary` | circle | +| `comparison` | `color.chart.series.secondary` | rounded square | +| `diagnostic` | `color.chart.series.tertiary` | lozenge | + +The marker mapping is used both in the plot and legend. Color is therefore not +the only way to tell series apart. + +Legend states also resolve through one documented style function: + +| State | Surface treatment | Additional cue | +|---|---|---| +| default | base surface | normal label and marker | +| hover | interactive surface | higher-emphasis marker | +| selected | selected surface | persistent accent underline | +| focused | state surface plus focus token | external focus ring | +| disabled | inset surface | disabled label and reduced marker opacity | + +Selected state is meaningful in both policies. Focus and hover are available +only in `actionable`, and selected and focused may coexist there. A disabled +series may not also be hovered, selected, or focused. At most one series in a +chart may own each transient or selection state. All colors route through semantic roles such as `color.chart.grid`, `color.chart.axis`, `color.chart.series.primary`, `color.focus`, and @@ -49,10 +141,27 @@ All colors route through semantic roles such as `color.chart.grid`, radius tokens. Repeated padding, legend gaps, and label spacing resolve through named Flex spacing tokens. +`performance_chart_x_tick_geometry`, +`performance_chart_y_tick_geometry`, and +`performance_chart_reference_geometry` are the only placement authorities for +grid lines, tick marks, visible text slots, and semantic bounds. The visual +legend resolves Flex exactly once and stores item, focus-ring, marker, text, and +selection-indicator bounds in `PerformanceChartLegendArtifact`; hit testing, +semantics, and text composition consume that artifact instead of running a +second layout pass. + +Axis reservations are not a shared square gutter. The horizontal x-axis footer +keeps its Flex-derived 56 logical-pixel height, while the unrotated y-axis label +column uses a separate Flex-derived 112 logical-pixel width. At the minimum +640-by-420 chart size, the focused contract proves that the `Frame time` label +and `milliseconds` unit slots exceed a conservative two-thirds-em width floor +while the plot retains positive width and height. Text slots and semantic +bounds still come from the same `PerformanceChartMetrics` geometry authority. + The implementation uses the public Canvas resource path for each line. A line is an immutable bounded path of filled segment ribbons because the current CPU -oracle implements path fills but not path strokes. Scatter marks and all other -chrome use retained display-list primitives. The complete Canvas seals and +oracle implements path fills but not path strokes. Role-shaped marks and all +other chrome use retained display-list primitives. The complete Canvas seals and verifies before its semantic and hit contributions become available. ## Required typography composition @@ -75,10 +184,16 @@ Each slot carries a stable semantic `NodeKey`, `SemanticTypeToken`, and slot. A showcase, screenshot, or native surface is incomplete if any slot is not rendered. Semantics do not excuse missing visible chart labels. +Ready charts own 24 slots in the focused three-series fixture. Loading, +ordinary empty, and error own three: title, status name, and status value. The +seven-by-seven meaningful-empty-axis fixture owns 21: title, four axis roles, +fourteen ticks, and two status strings. No non-ready state owns legend, +reference, or table text disguised as data. + The focused component test validates slot completeness, stable IDs, positive bounds, type roles, and color roles. It intentionally rasterizes only the Canvas-owned chart geometry. Therefore its CPU surface hash is renderer evidence -for the chart chrome, line, scatter, grid, references, legend materials, and +for the chart chrome, line, markers, grid, references, legend materials, and focus ring—not evidence that a native integration rendered the Text children. The component remains experimental until a Text-composed showcase and native accessibility execution cover this boundary. @@ -104,6 +219,22 @@ and every child retains its existing chart-local parent. Non-zero legend focus orders become `source order + focus_order_offset`; zero remains zero. The API never searches for an unused focus range or silently renumbers the host. +For a three-series chart, default read-only hosting contributes one hit node: +the Canvas root. Explicit actionable hosting contributes four: the Canvas root +plus three series hits. Semantic and display contribution counts are otherwise +unchanged by the policy. Host validation checks the policy-specific role, +focus/action fields, collection position, and exact hit count before mutation. +The focused four-sample fixture contributes 52 semantic nodes and owns 24 text +slots in either mode. + +Non-ready hosting always contributes one Canvas hit. The focused loading, +ordinary-empty, and error fixtures each contribute three semantic nodes +(Canvas, title, status); meaningful empty axes contribute 21. Host validation +requires zero legend artifacts and table dimensions, a policy-correct status +role/live region/value, exact status geometry, and the one-hit count before it +mutates any destination. Duplicate status IDs fail during preflight with no +display, semantic, or hit mutation. + The caller must provide an unsealed, internally consistent display list with enough operation/resource capacity and no resource IDs used by the chart. All chart semantic and hit IDs must also be absent. Missing parents, duplicate IDs, @@ -129,16 +260,24 @@ The Canvas contributes one named group. Its children provide: - title, axis-name, axis-unit, tick, baseline, and deadline text nodes with exact logical bounds; -- a named legend group whose series buttons expose description, selected, - disabled, focus order, focus action, and activation action; +- by default, a named series list whose read-only items expose description, + selected, disabled, value, set size, and position without actions or focus; +- in explicit actionable mode, a named legend group whose series buttons also + expose focus order, focused state, focus action, and activation action; - a table with `(sample count + 1)` rows and `(series count + 1)` columns; - one header row containing the x axis and every named series; and - one data row per sample with exact row/column coordinates and formatted values. +Those legend and table branches exist only in `ready`. Every non-ready tree has +one status node with its visible status name as `name`, its visible detail as +`value`, and the state-appropriate live-region priority. Meaningful empty axes +add only axis/tick text nodes; they still have no references, legend, or table. + The table is the accessible equivalent of the chart, not a summary. It uses -the same caller-provided names, units, and formatted values as the visible -composition slots. +the same caller-provided names, units, and formatted values as the plotted +samples. The focused contract checks every header and every sample cell, not a +representative cell. ## Stable IDs and Talkback @@ -147,10 +286,11 @@ targets. Axis, unit, tick, reference, table, row, and cell IDs derive deterministically from the chart ID through `semantic_generated_key` and public component ID helpers. -Enabled legend entries expose activate and focus actions; disabled entries are -discoverable but expose neither. Talkback can query every table cell by ID and -receives its exact role, row, column, and owned-text evidence. Pixel fallback -remains disabled in the focused contract. +Read-only legend entries are queryable by ID but expose no activate or focus +action. In actionable mode, enabled entries expose activate and focus actions; +disabled entries remain discoverable but expose neither. Talkback can query +every table cell by ID and receives its exact role, row, column, and owned-text +evidence. Pixel fallback remains disabled in the focused contract. ## Current evidence and limits @@ -160,13 +300,20 @@ Run: ./tools/test-performance-chart.sh ``` -The root-owned contract builds the same chart twice, verifies sealed Canvas -identity, Flex-derived geometry, token roles, complete text slots, semantic table -coordinates, stable-ID Talkback actions, fail-closed duplicate IDs, and +The root-owned contract builds the same chart twice and verifies sealed Canvas +identity, shared tick/reference geometry records, one-pass Flex legend +geometry, semantic series roles and non-color markers, all five legend state +treatments in actionable mode, the safe read-only default, policy-specific hit +and action truth, split axis extents at the minimum size, complete text slots, +all four content states, token-routed status anatomy, non-ready semantic/live +truth, zero-data suppression, full semantic-table equivalence, named Talkback +state bits, fail-closed +duplicate IDs/roles and invalid state, and pixel-identical CPU output. The hosted contract additionally verifies explicit -parents, exact contribution counts, root-only reparenting, stable child IDs, -focus-offset collision policy, atomic duplicate/missing-parent failures, and -deterministic caller-owned display content. +parents, one-hit read-only and four-hit actionable contribution counts, +one-hit non-ready state contribution, root-only reparenting, stable child IDs, +focus-offset collision policy, atomic status/duplicate/missing-parent failures, +and deterministic caller-owned display content. This slice does not provide arbitrary chart types, logarithmic/time scales, locale formatting, axis collision avoidance, zoom, pan, tooltip interaction, diff --git a/docs/components/scroll-viewport.md b/docs/components/scroll-viewport.md new file mode 100644 index 0000000..01e4384 --- /dev/null +++ b/docs/components/scroll-viewport.md @@ -0,0 +1,179 @@ +# ScrollViewport + +Status: experimental headless component + +`ScrollViewport` is Zagkit's retained scrolling host. It does not own a second +offset model: `src/layout/scroll.zag::ScrollState` is the sole authority for +viewport size, content size, logical offsets, revision, clamping, unconsumed +delta, anchoring, and reveal. The component derives one immutable geometry +record from that state and uses it for rendering, hit testing, semantics, +Talkback, and scrollbar chrome. + +This slice proves the reusable headless contract. It is not a claim of native +touch momentum, scrollbar dragging, overscroll effects, or reference-hardware +120 Hz certification. Those remain platform/input scheduler work. + +## Public model + +`scroll_viewport_spec` creates a `ScrollViewportSpec` with stable semantic and +hit parents, a stable `NodeKey`, exact logical bounds, and an accessible name. +The caller may then set: + +- `axis`: `vertical`, `horizontal`, or `both`; +- `direction`: LTR or RTL; +- `density`: the Flex density used for track thickness, margins, thumb minimum, + keyboard line increments, and page overlap; +- `enabled`, `focused`, and `focus_visible` as separate state truths; +- `focus_order` and `content_focus_order_offset`; +- `z_order` and explicit contribution limits; +- localized `description` and `value` text. + +The `ScrollState.viewport` must exactly equal `spec.bounds.size`. This equality +is deliberate: a caller cannot render one viewport, hit-test another, and +publish a third to accessibility. + +`scroll_viewport_geometry(spec, state)` returns: + +- the world-space viewport/clip rectangle; +- the physical visible content rectangle (mirrored from logical horizontal + offset under RTL); +- the exact content-to-world transform; +- horizontal and vertical track/thumb rectangles and visibility; +- both logical maxima and values; +- a deterministic evidence hash. + +## Hosted contribution + +`scroll_viewport_contribute` takes an already-built, sealed `DisplayList`, a +content-space `SemanticsTree`, and a content-space `HitTree`, then contributes +them into caller-owned destination builders. + +The display sequence is invariant: + +1. `save`; +2. viewport `clip_rect`; +3. content offset `concat_transform`; +4. the complete hosted content display list; +5. `restore`; +6. optional keyboard-focus ring; +7. tokenized horizontal/vertical tracks and thumbs. + +Content resources are copied into a staged destination before any caller-owned +builder changes. Resource IDs must not collide. The source display list stays +sealed and unchanged. + +Content semantic and hit roots are reparented beneath the viewport's stable ID; +descendant IDs and relationships are preserved. Non-zero content focus orders +receive the explicit offset. The viewport hit is below hosted children, so it +receives wheel input in empty space while children win at their exact higher +z-order. + +Hosted hit transforms currently accept translation plus identity scale (no +skew). Their translation is composed with the scroll transform and their local +clip is intersected with the authoritative visible content rectangle. Fully +offscreen hit nodes remain present but disabled, preserving parent topology +without admitting pixel actions outside the clip. General scaled/skewed hit +clip projection is intentionally fail-closed until the hit tree owns an exact +convex clip primitive. + +## Semantics and Talkback + +The viewport publishes a `group` node at `spec.id`. When enabled and +scrollable, it exposes `scroll`; when focusable, it separately exposes `focus`. +Its primary range is vertical when vertical overflow exists, otherwise +horizontal. + +Every visible axis additionally publishes a stable `slider`-role range node: + +- `scroll_viewport_horizontal_range_id(spec.id)`; +- `scroll_viewport_vertical_range_id(spec.id)`. + +These nodes expose exact minimum, maximum, current value, one-logical-pixel +step, track bounds, and a `scroll` action when enabled. This prevents a +two-axis viewport from collapsing its +accessibility truth into one ambiguous range. + +Descendant semantic bounds retain their complete transformed screen geometry; +the viewport ancestor supplies the clip to native adapters. Fully offscreen +descendants retain their transformed bounds, original `hidden` truth, stable +ID, and actions. They are not mislabeled hidden +merely because they are outside this viewport: Talkback can still query the ID, +while ID-derived pixel fallback honestly fails outside the viewport clip. + +`scroll_viewport_reveal_hosted_semantic_id` verifies that an ID is a descendant +of this viewport, inverts the artifact's exact transform back to content-space +bounds, and feeds those bounds into `scroll_viewport_reveal_focus`. A +composition may therefore release its source gallery/artifact after hosting; +it does not retain a second ID map or decode ID structure. The source-tree form, +`scroll_viewport_reveal_semantic_id`, remains available before hosting. The host then rebuilds +with the updated `ScrollState`; the same ID becomes visible and pointer +actionable. Keyboard Tab and native accessibility focus must use this reveal +step before dispatching an action to an offscreen target. No pixel guess or +screen-coordinate reverse lookup is involved. + +## Input and state flow + +All mutations return `ScrollViewportTransition`, containing the underlying +`ScrollMutation`, requested delta, consumed/unconsumed result, rejection reason, +input kind, and deterministic hash. + +- `scroll_viewport_wheel` handles two-axis wheel deltas and maps physical + horizontal direction to logical RTL offset. +- `scroll_viewport_key` supports arrows, Page Up/Down/Left/Right, Home, and End. +- `scroll_viewport_semantic_scroll` routes accessibility commands through the + same command reducer. +- `scroll_viewport_reveal_focus` performs nearest-edge reveal using exact + content bounds and RTL conversion. +- `scroll_viewport_reveal_semantic_id` joins ID lookup to focus reveal. + +Two Flex `xlarge` spacing units define the line increment; page movement +retains one `xlarge` overlap at each edge. ScrollState still clamps every result and reports +unconsumed boundary pressure for nested-scroll arbitration. + +Disabled viewports reject wheel, key, semantic, and reveal mutations without a +phantom revision. Their semantic and hit IDs remain inspectable, but root and +descendant actions are removed. A no-overflow viewport publishes no scroll +action, no range, and no track/thumb while still allowing an independently +configured focus target. + +## Token provenance + +No component-local RGBA values are accepted. Scrollbar chrome resolves through: + +| Part | Token | +|---|---| +| Track | `color.surface.inset` | +| Thumb | `color.border.emphasis` | +| Keyboard focus | `color.focus` | +| Thickness/margin | `FlexSpacingToken.tiny` | +| Minimum thumb | `FlexSpacingToken.xlarge` | + +The artifact repeats these token identities so inspectors and snapshot tests +can prove provenance rather than infer it from similar-looking pixels. + +## Atomicity, limits, and ownership + +Display operations/resources are staged. Semantic and hit destinations use one +checkpoint. Missing parents, duplicate IDs, focus-order collisions, resource +collisions, unsupported hit transforms, and display/resource/node capacity +failures leave every destination unchanged. A late semantic or hit failure +rolls back both node trees and discards the staged display. + +`ScrollViewportArtifact` owns no heap allocation. The caller retains ownership +of source and destination builders and frees them with their normal consumed +root destructors. Internal failed stages are always released. Tests use an +explicit `@consumes *ScrollViewportFixture` destructor; no mutable-borrow +destructor workaround is part of this component. + +Run the focused contract with: + +```sh +tools/test-scroll-viewport.sh +``` + +The suite covers LTR/RTL, both axes, deterministic wrapper order, token +provenance, exact ranges, Talkback query/scroll, offscreen ID lookup and reveal, +pixel-fallback failure, hit clipping, wheel/key/semantic mutations, boundary +handoff, disabled/no-overflow behavior, visible focus, resource collision, +late semantic/hit rollback, capacity rejection, source preservation, display +verification, deterministic replay identity, and consumed-root cleanup. diff --git a/docs/components/segmented-control.md b/docs/components/segmented-control.md new file mode 100644 index 0000000..913b677 --- /dev/null +++ b/docs/components/segmented-control.md @@ -0,0 +1,204 @@ +# SegmentedControl + +`SegmentedControl` is Zagkit's retained, single-selection control for switching +between a small set of peer views or modes. It is not a row of unrelated +buttons and it is not a decorative tag list. One group `NodeKey` owns a set of +stable tab IDs, exactly one enabled option is selected, and every selection +change is produced by the public reducer before state flows back into the next +frame. + +The component is experimental while the public declarative view and native +accessibility adapters are still being connected. Its headless contract is +executable now. + +## State and action contract + +`SegmentedControlModel` is application-owned state. It records the option +count, selected and roving-tab-stop indices, independent `has_focus` and +`focus_visible` truth, transient hover/press indices, and an enabled bit mask. +`segmented_control_model(count, selected)` creates a valid model with one +selection, one roving target, all options enabled, and no fabricated focus. +Selection and the roving target begin at the same option, but construction does +not paint a focus ring before the native shell delivers a real focus event. + +Send pointer, focus, keyboard, semantic-selection, and availability events to +`segmented_control_reduce`. The returned `SegmentedControlTransition` contains +the complete next model plus explicit `selection_changed`, `roving_changed`, +`focus_changed`, `activated`, and `rejected` truth. Roving-target movement is +therefore not misreported as native focus acquisition. The reducer never +mutates application state. It guarantees: + +- an enabled option is always selected; +- disabled options cannot be focused, pressed, selected, or activated; +- an inside pointer release selects only the matching pressed option; +- Pointer Cancel and focus loss clear pressed state; +- pointer down acquires focus without forcing a keyboard-style ring; +- host `focus_target` and keyboard navigation acquire visible focus; +- focus loss clears focus ownership and visibility while preserving the roving + target for deterministic re-entry; +- Left/Up and Right/Down navigation wrap and skip unavailable options; +- Home and End choose the first and last available option; +- Enter/Space activation and semantic selection use the same reducer path; and +- disabling the current selection chooses the next available option atomically + and rejects removal of the last available option. + +The shell maps physical keys to the logical `key_previous`, `key_next`, +`key_home`, `key_end`, and `activate_focused` events. Direction-aware shells +may swap previous/next for RTL without changing selection semantics. If a +keyboard event arrives while the model is unfocused, navigation starts from the +selected option and establishes visible focus. Hover, press, and focus shown in +a native preview must come from actual input/focus events; a gallery may build +an explicit static state matrix, but it is not runtime interaction evidence. + +## Retained authoring contract + +Create a `SegmentedControlSpec` with `segmented_control_spec`, then set its +description, focus-order base, z-order, density, model, and optional transaction +limits. Provide a caller-owned `ArrayList[SegmentedControlOption]` whose length +equals `model.segment_count`, and call `segmented_control_emit` with the frame's +`DisplayList`, `HitTree`, and `SemanticsTree`. + +```zag +let options: ArrayList[SegmentedControlOption] = + make[SegmentedControlOption](3); +push[SegmentedControlOption](&options, SegmentedControlOption{ + .id = node_key(101), .label = "State", .description = "Inspect state", +}); +push[SegmentedControlOption](&options, SegmentedControlOption{ + .id = node_key(102), .label = "Layout", .description = "Inspect layout", +}); +push[SegmentedControlOption](&options, SegmentedControlOption{ + .id = node_key(103), .label = "Render", .description = "Inspect rendering", +}); + +let model = segmented_control_model(3, 0); +let spec = segmented_control_spec(node_key(100), hit_root_key(), bounds, + "Inspector mode", model); +spec.purpose = SegmentedControlPurpose.value_picker; +let built = segmented_control_emit(spec, &options, + &display, &hits, &semantics); + +let transition = segmented_control_reduce(model, + segmented_control_event(SegmentedControlEventKind.key_next, + segmented_control_no_index())); +model = transition.model; // selection, roving, focus, and transient state flow together +free[SegmentedControlOption](&options); +``` + +The convenience constructor is for static/literal group names in the current +experimental API. If the group name or description is dynamically owned, +construct the `SegmentedControlSpec` literal in the same caller scope as that +backing and keep it alive through emission. Zag intentionally rejects marking a +constructor input as a call-duration borrow and then returning that view; an +explicit returned-borrow relation is a separate future language contract, not +something Zagkit fakes with an unsafe lifetime. + +Choose semantics explicitly with `SegmentedControlSpec.purpose`: +`view_switcher` (the default) exposes a `tab_list` with `tab` children for peer +content views; `value_picker` exposes a `radio_group` with `radio` children for +mutually exclusive values or filters. The pixels and reducer are shared, but +Zagkit does not mislabel a value choice as page navigation. + +Options are borrowed for the call and never retained. Labels are required, +strict UTF-8, NUL-free, and bounded to 4,096 bytes; descriptions use the same +text rules and are bounded to 16,384 bytes. IDs must be stable and unique within +the group. The component supports 2 through 12 options; larger navigation sets +belong in tabs, a sidebar, or a menu rather than an unreadable segmented rail. + +On success, `SegmentedControlArtifact` reports the exact group bounds, selected +ID/index/bounds, roving index, focus ownership/visibility, retained operation +range, group tree locations, Text token, inspectable rail tokens, and a +deterministic evidence hash. + +## Placement and Text + +Flex owns spacing. The rail uses `spacing.micro` for its inset and connected-item +gap. Every segment has a minimum 44-by-44 logical-pixel target. Width is divided +in fixed-point units and the remainder is distributed deterministically, so the +first edge, every gap, and the final edge conserve the full rail width. +Undersized controls fail validation instead of gaining an invisible hit halo. + +The component emits material chrome and returns deterministic label slots via +`segmented_control_content_bounds(spec, index)`. Callers render Zagkit `Text` +inside that rect with `SemanticTypeToken.label` and +`segmented_control_style(spec, index).label_color_token`. Semantics copy the +option label during emission, so accessibility and Talkback do not depend on +glyph visibility. Placeholder bars are not a supported label implementation. + +## Token and visible-state system + +Every visual choice has semantic-token provenance. The rail exposes +`color.surface.inset`, `color.border.subtle`, `radius.control`, and +`elevation.base` through the artifact. Per-option style resolution uses named +roles only: + +- default: `color.surface.base`; +- hover: `color.surface.interactive`; +- visible keyboard focus: `color.focus` with a geometrically thicker retained + ring; +- press: `color.accent.emphasis` with a two-pixel inset treatment; +- disabled: `color.surface.inset` and `color.text.disabled`; and +- selected: `color.surface.selected` with `color.border.emphasis` and an + `elevation.panel` thumb shadow. + +Selection never relies on fill color. A selected option also emits a persistent +three-logical-pixel underline owned by the same stable ID, and its semantic node +sets `selected` with the text value `Selected`. Focus and press also change +geometry, not just hue. This state matrix is verified from the public style and +retained display operations. `has_focus` is independent from +`focus_visible`: pointer focus is real semantic focus but does not inherit the +keyboard focus ring, while keyboard or explicit host focus sets both. +Focus-ring modality participates in visual/component evidence, while the +semantic tree remains identical for pointer and keyboard focus on the same ID. + +## Accessibility and Talkback + +The parent is a named semantic `tab_list` or `radio_group`; each option is a +matching `tab` or `radio` with one-based position, set size, and exact bounds. +Exactly one enabled option—the model's roving target—has the group's sequential +focus order and focusable hit-node bit. All enabled options still expose +`activate`, `select`, and programmatic `focus`, so assistive technology and +Talkback can address them directly by stable ID. Disabled options remain +discoverable and queryable but expose no action and are removed from pointer and +focus routing. + +The semantic `focused` state follows actual focus ownership and the roving ID, +not focus-ring modality. Consequently pointer-focused controls report semantic +focus while keeping `focus_visible = 0`; Talkback exposes that independent state +in its focused flag. Blur removes semantic focus but leaves the roving tab stop +ready for the next traversal. + +For every option, its outer visual operation, hit node, semantic node, focus +target, and Talkback response use the exact same `NodeKey` and `Rect`. Talkback +can therefore query and key-activate a segment by ID; pixel targeting is not +needed. The reducer remains the application-side authority that turns the +emitted action into a new selected index. + +## Atomic failure and capacity + +Emission checkpoints all three caller-owned builders. Invalid geometry or +content fails before mutation. Display errors, duplicate/missing hit IDs, +semantic focus collisions, and display-, hit-, or semantic-transaction capacity +exhaustion restore every builder, including copied semantic text and diagnostic +state. + +`SegmentedControlBuildLimits` are deterministic per-emission transaction +budgets, not a claim that process allocator OOM is recoverable. Defaults cover +the maximum supported control. A host using fixed frame arenas can lower them +and receive a structured `SegmentedControlCapacityError` without partial UI. + +The executable contract in `tests/segmented_control_contract.zag` covers model +invariants, all 180,224 enabled-mask/directional-key cases for 2 through 12 +options, default/hover/focus/pressed/disabled/selected styles, resting selection +without false focus, pointer-versus-keyboard focus visibility, one roving tab +stop, every supported count and fixed-point placement remainder, token +provenance, non-color state geometry, retained-tree identity, keyboard actions, +Talkback, immutable display evidence, determinism, input validation, and rollback +for all three capacity classes. + +The suite also builds label and description views from caller-owned dynamic +byte buffers, emits the control, releases the option list and both caller +buffers, and only then queries the retained semantic node through Talkback. +Compilation proves no aggregate loan survives the call; runtime assertions +prove the semantic tree owns its independent text copy. Literal-only fixtures +are not accepted as sufficient borrow evidence. diff --git a/docs/components/surface.md b/docs/components/surface.md new file mode 100644 index 0000000..22cb512 --- /dev/null +++ b/docs/components/surface.md @@ -0,0 +1,181 @@ +# Surface + +`Surface` is Zagkit's first-party retained container for cards, panels, and +floating content. It is not a one-off rectangle helper. The same component +contract owns hierarchy, interaction state, Flex content placement, semantics, +hit testing, and stable-ID automation. + +Status: `experimental`. The deterministic CPU material is authoritative. The +current material resolver uses translucent fills, edge highlights, and tokenized +shadows; its documented CPU fallback reason remains visible until real backdrop +blur is implemented and certified. + +## Hierarchy tiers and token provenance + +Every tier resolves through the shared semantic token registry. A returned +`SurfaceArtifact` records both the material's base tokens and any state override, +so inspection never has to infer provenance from pixels. + +| Surface tier | Material | Base fill | Radius | Material elevation | Flex content spacing | +|---|---|---|---|---|---| +| `base` | `material.glass.shell` | `color.surface.base` | `radius.panel` | `elevation.base` | `medium` | +| `panel` | `material.glass.panel` | `color.surface.panel` | `radius.panel` | `elevation.panel` | `large` | +| `raised` | `material.glass.raised` | `color.surface.raised` | `radius.card` | `elevation.raised` | `xlarge` | +| `overlay` | `material.glass.overlay` | `color.surface.raised` | `radius.card` | `elevation.overlay` | `xlarge` | + +The contract deliberately records two elevation fields: + +- `material_elevation_token` is the hierarchy tier's stable provenance. +- `elevation_token` is the current rendered state. A pressed or unavailable + card can flatten to `elevation.base` without lying about its material tier. + +Likewise, `material_fill_token` records the tier's base fill while `fill_token` +records the current hover, selected, pressed, loading, error, or disabled fill. +`edge_token`, `border_token`, `content_color_token`, `focus_token`, and +`state_token` complete the inspectable color chain. Use +`semantic_material(...)`, `semantic_color(...)`, `semantic_radius_name(...)`, +and `semantic_elevation(...)` to resolve their stable names and values. + +## Modes + +`SurfaceMode.group` is a structural content group. It emits: + +- a visible material owned by the Surface `NodeKey`; +- a non-actionable hit node with the same key, retained for inspection and + ID-to-pixel fallback but excluded from normal input routing; +- a `group` semantic node with no action mask or focus order. + +`SurfaceMode.actionable` is a card that represents one real action. It emits a +focusable hit node and a semantic `button` with `activate` and `focus` only while +the card is available. Loading and disabled cards remain queryable by ID but +their actions fail closed. + +The constructors are: + +```zag +let panel: SurfaceSpec = surface_spec( + node_key(1200), hit_root_key(), bounds, + "Inspector", SurfaceTier.panel); + +let card: SurfaceSpec = surface_action_spec( + node_key(1201), hit_root_key(), bounds, + "Open renderer", SurfaceTier.raised); +card.focus_order = 4; +``` + +For actionable surfaces, feed events through `surface_reduce`. It delegates the +actual transition law to Zagkit's canonical Button reducer, so cards and buttons +cannot drift on press, cancellation, loading, disabling, or error precedence. +Groups reject every event without mutation. + +## Interaction state language + +The canonical states are `rest`, `hover`, `focus`, `pressed`, `selected`, +`disabled`, `loading`, and `error`. Color is never the sole carrier: + +| State | Geometric or semantic treatment beyond color | +|---|---| +| `rest` | semantic value `Ready`; tier material, edge, radius, and elevation | +| `hover` | trailing geometric marker; semantic value `Hovered` | +| `focus` | retained semantic `focused = 1`; outer ring only for keyboard-visible focus | +| `pressed` | two-pixel depressed chrome and flattened elevation; value `Pressed` | +| `selected` | persistent underline; semantic `selected = 1` and value `Selected` | +| `disabled` | two-line unavailable mark; disabled hit/action/focus truth; value `Disabled` | +| `loading` | two-part progress rail; disabled action; value `Loading` | +| `error` | trailing state rail sized by Flex `tiny` and `xlarge`; value `Error` | + +Combined state fields are allowed only when their truth is coherent. A disabled +surface cannot remain hovered, focused, pressed, or loading. Loading and error +cannot both be set. Invalid combinations fail before any retained destination is +changed. + +`live_region` defaults to `off`; a generic card does not assume that a loading +or error change is urgent enough to interrupt assistive technology. Authors can +set `polite` or `assertive` deliberately for either group or actionable mode. +The contract fixtures exercise both explicit choices and include that choice in +deterministic evidence. + +Actual focus and focus-ring modality are separate truth. Pointer focus sets +`focused = 1` for semantics, Talkback, and focus ownership while keeping +`focus_visible = 0`; keyboard focus sets both fields. Only `focus_visible` +paints the outer ring. A standalone ring with no actual focus is an invalid +interaction state. + +## Flex placement and minimums + +`SurfaceArtifact.content_bounds` is the only supported placement rectangle for +children. It is derived from `content_spacing_token` and the selected +`FlexDensity`; consumers must not reproduce its padding with literals. Status +treatments reserve trailing space when possible and retain at least one logical +pixel of content width under tight constraints. + +Actionable cards enforce a minimum target of 44 logical pixels on both axes. +Tier padding can require more: a raised or overlay surface at standard density +needs 56 logical pixels to preserve two `xlarge` insets and an eight-pixel +content extent. Structural groups enforce the same content-preservation rule +without pretending to be pointer targets. + +The visual radius is resolved from the named radius token, then clamped to the +actual rectangle. The artifact records both `radius_token` and +`resolved_radius`, preserving token provenance while keeping small valid +surfaces geometrically valid. + +## One ID, four consumers + +The Surface `id` is used unchanged for: + +1. every emitted `DisplayOp.owner`; +2. the retained `HitNode.id`; +3. the retained `SemanticsNode.id`; +4. Talkback query/action lookup and explicit semantic-ID-derived pixel fallback. + +The component never manufactures a parallel accessibility or automation ID. +The semantic bounds and hit bounds are the same `SurfaceSpec.bounds`, while +pixels use that geometry plus documented focus/shadow extents. Disabled and +group hits stay inspectable through the explicit disabled-inclusive inspection +path but never enter normal input routing. + +## Capacity, atomicity, and evidence + +`SurfaceBuildLimits` provides explicit per-build budgets for display operations, +hit nodes, and semantic nodes. Zero is a valid fail-closed test budget; negative +limits are invalid specs. Exhaustion reports the exact +`SurfaceCapacityError`. + +Emission checkpoints the DisplayList, HitTree, and SemanticsTree before its +first contribution. Display errors, duplicate IDs, focus-order collisions, +semantic errors, and capacity failures restore all lengths, revisions, hashes, +error state, and newly copied semantic text. A build is either fully present in +all three retained trees or absent from all of them. + +`SurfaceArtifact.evidence_hash` deterministically covers identity, geometry, +parent, mode, tier, Flex density, state, token provenance, resolved radius, +visible treatment flags, role, name, and description. Identical inputs must +produce identical component, DisplayList, and semantic-tree evidence. + +## Text lifetime + +Surface names are caller-owned views. Both constructors declare +`name: @retained_by_return []u8` because the returned `SurfaceSpec` retains that +view until emission. The caller's backing must remain alive through the last use +of the spec. `surface_emit` copies name and description into the retained +SemanticsTree; the caller can release its backing after emission. + +This is enforced by Zag rather than hidden behind a component copy. The focused +test runner includes both a positive dynamic-name case and a negative strict +compile case that releases backing before `surface_emit`; the negative case must +be rejected with the resource/lifetime diagnostic. + +## Verification + +Run: + +```sh +tools/test-surface.sh +``` + +The contract covers hierarchy/token resolution, Flex geometry, group and button +semantics, retained focus truth, all eight interaction states, non-color state +treatments, hit and Talkback routing, minimum targets, invalid inputs, +determinism, dynamic returned-view lifetime, explicit capacity failures, and +atomic rollback. diff --git a/docs/design/material-tiers.md b/docs/design/material-tiers.md new file mode 100644 index 0000000..c258385 --- /dev/null +++ b/docs/design/material-tiers.md @@ -0,0 +1,98 @@ +# Material tier contract (experimental, pre-RFC-0007) + +- Status: experimental draft, evolves candidate direction A +- Token manifest: `visual-direction-tokens/direction-a2-glass-clarity.json` +- Blocks nothing; RFC 0007 remains the acceptance authority + +Zagkit's glass language is only sustainable if translucency is architecturally +bounded. This contract makes depth a semantic statement instead of a decorative +knob: **a surface may only be translucent when it genuinely floats over live +content.** + +## The three tiers + +| Tier | Meaning | Translucency | Area budget | Examples | +|---|---|---|---|---| +| `solid` | Content-bearing surfaces | none | unbounded | canvas, panels, cards, insets, tables | +| `veil` | Persistent chrome floating over app content | blur 20dp, tint 0.18 | combined veil area ≤ 25% of viewport | header bar, navigation rail, command palette, toasts | +| `glass` | Transient overlays | blur 36dp, tint 0.22 | one live glass layer per interaction | menus, popovers, dialog scrims | + +Rules that follow from the tiers: + +- A `solid` surface never records a blur radius. A recorded blur radius on a + `veil` or `glass` material is not a claim that backdrop blur executed; the + deterministic CPU fallback reason stays honest, exactly as the existing + material contract requires. +- Reduced transparency is a tier demotion, not a per-screen special case: + `veil → color.surface.panel` opaque, `glass → color.surface.raised` opaque, + each with a recorded fallback reason. +- Compact density demotes `veil` to `solid` and reduces every radius token by + one step. Dense professional layouts (CAD, tables) therefore never pay + glass sampling cost, which retires direction A's named overdraw risk. + +## One scene light + +Every window has exactly one key light (azimuth 40°, zenith 60°). All depth +cues derive from it; no component invents its own lighting: + +- **Edge lighting replaces borders on elevated surfaces.** An elevated surface + receives a 1dp `color.edge.highlight` hairline on its top edge and a shadow + falloff below. Stroked `color.border.subtle` outlines are reserved for flat + and inset elements (inputs, wells, separators). +- **Two-layer analytic shadows.** Every elevated surface casts a pair: + - a tight *contact* layer (small offset, small spread, higher alpha) that + seats the surface, and + - a soft *ambient* layer (larger offset, wide spread, lower alpha) that + communicates height. + Both are analytic rounded rectangles with alpha falloff, so the CPU oracle + rasterizes them exactly; no gaussian pass is required for correctness. +- Elevation transitions animate the shadow pair, never the fill. + +## Luminance ladder + +Structural depth is encoded as ordered relative luminance so hierarchy +survives high contrast and reduced transparency: + +``` +dark: inset < canvas < base < panel < raised (~+1.5% L per step) +light: raised > panel > base > canvas > inset +``` + +Chroma is reserved for meaning: accent, focus, status, and chart series own +all saturated color. Structural surfaces stay near-neutral with only a +cool-to-warm ambient drift (recessed cool, raised warm) using the existing +`color.ambient.cool` / `color.ambient.warm` roles. + +## Motion + +Springs only, three temperaments (`motion.snappy`, `motion.gentle`, +`motion.expressive` in `src/design/tokens.zag`). Material-specific rules: + +- Glass **condenses** in: blur radius and tint animate together on entrance. + Opacity-only fades are the reduced-motion substitution. +- Press drops elevation to `base` with the snappy spring; release restores it + with the gentle spring, preserving velocity through interruption. + +## Mapping to executable tokens + +`src/design/tokens.zag` currently exposes `SemanticMaterialToken` +{`showcase_backdrop`, `shell`, `panel`, `raised`, `overlay`}. Tier assignment: + +| Material token | Tier | +|---|---| +| `material.showcase.backdrop` | solid | +| `material.glass.shell` | veil | +| `material.glass.panel` | solid | +| `material.glass.raised` | solid | +| `material.glass.overlay` | glass | + +The two-layer shadow is executable through `ResolvedElevationToken`'s +contact/ambient fields; the edge highlight is executable through the existing +`color.edge.highlight` role at per-elevation alpha. + +## Non-claims + +This document does not select the 1.0 visual direction, does not claim +backdrop blur executes anywhere, and does not modify the comparison matrix +required by `visual-direction.md`. It exists so the showcase evidence for +direction A reflects a disciplined glass system rather than an unbounded one. diff --git a/docs/design/semantic-tokens.md b/docs/design/semantic-tokens.md new file mode 100644 index 0000000..5c258c2 --- /dev/null +++ b/docs/design/semantic-tokens.md @@ -0,0 +1,56 @@ +# Semantic token contract + +Zagkit components consume semantic roles, never screen-local RGBA values. The +current token set is experimental while RFC 0007 remains open, but its role +boundaries are already strict: a similar resolved color does not make two +tokens interchangeable. + +## Color roles + +| Family | Tokens | Meaning | +|---|---|---| +| Canvas and surfaces | `color.canvas`, `color.surface.base`, `color.surface.panel`, `color.surface.raised`, `color.surface.inset` | Structural depth from the application backdrop through recessed content. | +| Interaction surfaces | `color.surface.interactive`, `color.surface.selected` | Transient pointer response and retained selection. Neither means focus. | +| Edges | `color.border.subtle`, `color.border.emphasis`, `color.edge.highlight` | Separation, emphasized boundaries, and material lighting edges. | +| Text | `color.text.primary`, `color.text.secondary`, `color.text.disabled` | Content emphasis and availability. | +| Actions | `color.accent`, `color.accent.emphasis` | Primary action identity and its pressed/emphasized treatment. | +| Focus | `color.focus` | Keyboard-visible focus only. Actual focus also exists independently in semantics. | +| Runtime status | `color.status.healthy`, `color.status.experimental`, `color.status.error` | Health or availability state. Status always has redundant visible text and semantic value. | +| Showcase category | `color.category.performance`, `color.category.semantics`, `color.category.motion`, `color.category.input`, `color.category.renderer` | Stable subject classification. Category never implies health, selection, or action. | +| Chart series | `color.chart.series.primary`, `color.chart.series.secondary`, `color.chart.series.tertiary` | Dataset identity scoped to a chart legend and semantic table. | +| Chart structure | `color.chart.grid`, `color.chart.axis` | Non-data chart anatomy. | +| Material lighting | `color.shadow`, `color.ambient.cool`, `color.ambient.warm` | Depth and environmental lighting. These never carry application state. | + +The status rail uses category colors only as redundant classification beside +the category name. Its value text carries current runtime status. It owns no +selection, focus order, action, or enabled hit target. The canonical Surface +retains disabled inspection bounds, while normal input routing misses the rail; +it therefore cannot masquerade as a second navigation pattern. It contains no +navigation actions; the native semantics publish that same contract. + +## Type, shape, depth, and material + +The type ramp is `type.display`, `type.title`, `type.heading`, `type.body`, +`type.label`, `type.caption`, and `type.code`. A showcase route must render the +complete ramp with visible role names; source enumeration alone is not proof. + +Shape uses `radius.control`, `radius.card`, `radius.panel`, and `radius.pill`. +Depth uses ordered `elevation.base`, `elevation.panel`, `elevation.raised`, and +`elevation.overlay` roles. Materials bind a fill, tint, edge, radius, elevation, +and an honest deterministic CPU fallback reason. A blur radius recorded by a +material is not a claim that backdrop blur executed. + +## Inspector evidence + +The native Linux showcase now has an experimental, stateful token-inspector +overlay for the retained main Surface. It visibly reports the selected +`NodeKey`, canonical component and tier, material, fill, type, spacing, +radius/elevation, environment, and deterministic CPU fallback. The same stable +IDs are present in semantics and Talkback, and the toggle becomes unavailable +when the overlay's Flex minimum cannot fit. + +This is the first executable inspector slice, not completion of the inspector +contract. Selection is currently pinned to the main Surface; resolved numeric +values, arbitrary-node picking, interaction-state provenance, and the complete +shared Flex trace remain required before showcase conformance can pass. See the +[executable boundary](../showcase/token-inspector.md). diff --git a/docs/design/showcase-conformance.md b/docs/design/showcase-conformance.md index 6d5a8b7..2141a7e 100644 --- a/docs/design/showcase-conformance.md +++ b/docs/design/showcase-conformance.md @@ -43,6 +43,28 @@ but it must be labeled experimental and cannot promote the visual system. independently navigable native compositions. A single dashboard containing one instance of each shape does not count as proof that the system scales. +### Material, lighting, and motion truth + +- Glass is a functional foreground layer for navigation, controls, and + transient presentation. It is not a decorative fill applied to every card; + ordinary content surfaces remain visually quieter so hierarchy survives. +- A production glass claim requires retained backdrop sampling, blur, + background-adaptive tint and contrast, edge lensing/refraction, coherent + highlights, and soft multi-lobe shadows. A translucent rounded rectangle or + fixed gradient may be an explicitly labelled CPU fallback, but is not + liquid-glass conformance. +- Material thickness changes with scale and presentation role: a compact + control and an expanded menu cannot reuse one fixed shadow, tint, or optical + response. Nearby content may influence the material only through bounded, + deterministic inputs recorded in the display-list evidence. +- Interaction can energize, lift, flex, or morph a material only when the + motion is interruptible, reversible, refresh-rate aware, and continuous with + the pointer or gesture. Static before/after states do not prove fluidity. +- Reduced transparency, increased contrast, reduced motion, grayscale, and + color-vision variants have explicit substitutions that preserve legibility, + focus, selection, and action truth. The system setting and chosen + substitution are inspectable tokens, never silent renderer branches. + ### Interaction states - Canonical Button, IconButton, navigation item, segmented control, field, @@ -71,7 +93,8 @@ but it must be labeled experimental and cannot promote the visual system. - Navigation exposes one canonical active location. Contextual inspectors and system status are labeled and styled as their actual roles. - A read-only status rail owns no navigation actions, focus stops, selection, - or pointer hit targets. Its category/status marks are either documented + or enabled pointer targets. Its structural Surface may retain disabled bounds + for inspection, but normal input routing must miss it. Category/status marks are either documented semantic symbols with redundant text or explicitly decorative; arbitrary colored-dot filler is a conformance failure. - Every enabled navigation item activates its named destination: the selected diff --git a/docs/design/visual-direction-tokens/README.md b/docs/design/visual-direction-tokens/README.md index 3bc71c7..64aaa34 100644 --- a/docs/design/visual-direction-tokens/README.md +++ b/docs/design/visual-direction-tokens/README.md @@ -4,5 +4,9 @@ This folder stores the candidate direction token bundles required by `docs/design/visual-direction.md`. - [A] `direction-a-glass-clarity.json` +- [A2] `direction-a2-glass-clarity.json` — evolved A draft: material tiers, + luminance ladder, one scene light, two-layer shadows. The showcase tokens in + `src/design/tokens.zag` implement this draft; see + `docs/design/material-tiers.md`. - [B] `direction-b-precision-fabric.json` - [C] `direction-c-vector-utility.json` diff --git a/docs/design/visual-direction-tokens/direction-a2-glass-clarity.json b/docs/design/visual-direction-tokens/direction-a2-glass-clarity.json new file mode 100644 index 0000000..57caa9d --- /dev/null +++ b/docs/design/visual-direction-tokens/direction-a2-glass-clarity.json @@ -0,0 +1,172 @@ +{ + "schema_version": 2, + "direction_id": "direction-a2-glass-clarity", + "evolves": "direction-a-glass-clarity", + "focus_language": "depth as truth: a single scene light, a strict luminance ladder, and glass reserved for surfaces that genuinely float", + "principles": { + "depth_is_semantic": "Elevation always encodes stacking truth. A surface is only translucent when it floats over live content; content-bearing surfaces are opaque.", + "one_scene_light": "Every edge highlight, shadow, and specular hint derives from one window-global key light. No per-component shadow improvisation.", + "chroma_only_for_meaning": "Accent, focus, status, and chart series own all saturated color. Structural surfaces stay near-neutral so glass reads calm.", + "glass_recedes_with_density": "Compact density demotes veil materials to solid and shrinks radii by one step; premium at leisure, utilitarian under load." + }, + "material_tiers": { + "solid": { + "meaning": "Content-bearing surfaces: canvas, panels, cards, insets.", + "translucency": "none", + "budget": "unbounded area", + "cpu_oracle": "exact fill plus analytic gradient bands" + }, + "veil": { + "meaning": "Persistent chrome floating over app content: header bar, navigation rail, command palette, toasts.", + "translucency": "blur 20dp, tint alpha 0.18", + "budget": "combined veil area must stay under 25% of the viewport", + "cpu_oracle": "vertical ambient-tint band + edge highlight, fallback reason recorded", + "reduced_transparency_fallback": "surface.panel opaque" + }, + "glass": { + "meaning": "Transient overlays only: menus, popovers, dialog scrims.", + "translucency": "blur 36dp, tint alpha 0.22", + "budget": "one live glass layer per interaction; scrims excluded", + "cpu_oracle": "warm ambient-tint band + overlay shadow pair, fallback reason recorded", + "reduced_transparency_fallback": "surface.raised opaque" + } + }, + "palette": { + "comment": "Dark theme is primary for the showcase. Structural surfaces follow a strict luminance ladder; hue drift between steps is limited to a cool-to-warm ambient shift.", + "canvas": "#0B0E14", + "surface": "#12161F", + "surface-panel": "#171C27", + "surface-raised": "#1E2431", + "surface-inset": "#0D1018", + "surface-light": "#F5F7FB", + "surface-panel-light": "#EDF0F7", + "surface-raised-light": "#FFFFFF", + "text-primary": "#F0F4FC", + "text-secondary": "#A9B4C8", + "text-disabled": "#5A6478", + "text-primary-light": "#0B1020", + "text-secondary-light": "#3D4A61", + "brand": "#5B5BF0", + "brand-emphasis": "#7C7CF5", + "brand-on-surface": "#FFFFFF", + "focus": "#37D6EE", + "danger": "#F0554D", + "warning": "#E8A13D", + "success": "#3FBF7A", + "shadow-contact": "rgba(2, 6, 16, 0.42)", + "shadow-ambient": "rgba(2, 6, 16, 0.26)", + "edge-highlight": "rgba(190, 210, 255, 0.16)", + "ambient-cool": "rgba(80, 140, 255, 0.06)", + "ambient-warm": "rgba(230, 120, 190, 0.05)", + "reduced-contrast-overlay": "rgba(2, 8, 23, 0.72)" + }, + "luminance_ladder": { + "comment": "Structural depth is encoded as ordered relative luminance steps so hierarchy survives high contrast and reduced transparency.", + "dark": { "inset": 0.028, "canvas": 0.035, "base": 0.052, "panel": 0.065, "raised": 0.085 }, + "light": { "raised": 1.0, "panel": 0.93, "base": 0.90, "canvas": 0.87, "inset": 0.83 } + }, + "typography": { + "family": "Inter", + "family_fallbacks": ["Fira Sans", "Noto Sans", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans JP", "Noto Sans Thai"], + "scale_system": "fluid-4", + "baseline_grid_dp": 4, + "line_height": 1.4, + "letter_spacing_display": "-0.015em", + "letter_spacing_body": "0.0em", + "numerals": "tabular in tables, charts, and inspectors", + "monospace": "JetBrains Mono", + "minimum_click_target_dp": 24 + }, + "spacing": { + "unit": "4dp", + "base": 4, + "inline-x": 12, + "inline-y": 10, + "gutter": 16, + "panel-padding": 20, + "component-gap": 14, + "density": { + "compact": 0.9, + "standard": 1.0, + "expanded": 1.15 + }, + "density_material_rule": "compact demotes veil to solid and reduces every radius token by one step" + }, + "shape": { + "comment": "Continuous-curvature (superellipse) corners are the signature shape; the CPU path rasterizer owns them exactly.", + "corner_model": "continuous-curvature", + "inset-radius": 8, + "control-radius": 10, + "card-radius": 14, + "panel-radius": 16, + "overlay-radius": 18, + "pill": true, + "concentric_rule": "outer_radius = inner_radius + padding", + "focus_ring_radius": "control-radius + 4" + }, + "elevation": { + "comment": "Every elevated surface casts a two-layer analytic shadow: a tight contact layer and a soft ambient layer, both derived from the scene key light.", + "levels": { + "base": { "contact": null, "ambient": null, "edge_highlight_alpha": 0.10 }, + "panel": { "contact": { "y": 1, "blur": 4, "alpha": 0.30 }, "ambient": { "y": 6, "blur": 18, "alpha": 0.16 }, "edge_highlight_alpha": 0.14 }, + "raised": { "contact": { "y": 2, "blur": 6, "alpha": 0.34 }, "ambient": { "y": 10, "blur": 28, "alpha": 0.20 }, "edge_highlight_alpha": 0.18 }, + "overlay": { "contact": { "y": 3, "blur": 8, "alpha": 0.38 }, "ambient": { "y": 16, "blur": 40, "alpha": 0.26 }, "edge_highlight_alpha": 0.22 } + }, + "focus-ring-thickness": 2 + }, + "materials": { + "light": { + "key_azimuth_deg": 40, + "key_zenith_deg": 60, + "ambient_intensity": 0.42, + "ambient_tint": "rgba(190, 210, 255, 0.16)", + "rim_intensity": 0.18, + "rule": "edge highlights appear on the top and leading edges only, opposite falloff feeds the contact shadow" + }, + "veil": { + "blur_px": 20, + "tint_alpha": 0.18, + "specular_gain": 0.10, + "edge_highlight_alpha": 0.16, + "reduced_transparency_fallback": "surface-panel-opaque" + }, + "glass": { + "blur_px": 36, + "tint_alpha": 0.22, + "vignette_alpha": 0.16, + "specular_gain": 0.12, + "edge_highlight_alpha": 0.20, + "reduced_transparency_fallback": "surface-raised-opaque" + } + }, + "motion": { + "model": "springs-only", + "temperaments": { + "snappy": { "settle_ms": 130, "use": "press, toggle, disable" }, + "smooth": { "settle_ms": 200, "use": "hover, focus, selection, panel resize" }, + "expressive": { "settle_ms": 320, "use": "overlay entrance, route transitions, material condensation" } + }, + "material_entrance": "glass condenses: blur radius and tint animate together; opacity fades are reserved for reduced motion", + "layout-interruption": "preserve-velocity", + "reduced_motion_substitution": "snap or bounded opacity fade, never a global duration multiplier" + }, + "interaction_states": { + "focus": { + "ring": "focus", + "ring_width": 2, + "inner_separation_width": 1, + "focus_alpha": 1.0, + "rule": "focus ring pairs a 2dp outer ring with a 1dp inner separation hairline so it stays visible on glass and solid in every theme" + }, + "hover": { "treatment": "surface.interactive fill plus trailing state marker; never color-only" }, + "pressed": { "treatment": "chrome inset 2dp, elevation drops to base, spring snappy" }, + "disabled": { "opacity": 0.45, "contrast_delta": 0.55, "redundant_mark": true } + }, + "contrast_floors": { + "text_on_solid": 4.5, + "text_on_veil": 7.0, + "large_text": 3.0, + "focus_against_adjacent": 3.0, + "enforcement": "token inspector reports computed contrast per node; failures block showcase conformance" + } +} diff --git a/docs/evidence/goal-progress-live.json b/docs/evidence/goal-progress-live.json index 518e3b0..b0dbf65 100644 --- a/docs/evidence/goal-progress-live.json +++ b/docs/evidence/goal-progress-live.json @@ -1,11 +1,11 @@ { - "generated_at": "2026-08-06T23:02:37-07:00", + "generated_at": "2026-08-09T13:03:05-07:00", "zagkit_version": "0.1.0-experimental.0", "compiler_commit": "43870455a07bf8e7d4adf38fad807fe3baee4e26", "goal": { - "total_items": 97, - "completed_items": 28, - "blocked_items": 69 + "total_items": 100, + "completed_items": 26, + "blocked_items": 74 }, "upstream": { "total_entries": 17, @@ -17,6 +17,6 @@ "platforms": { "required": "linux, macos, windows, ios, android", "capabilities_total": 45, - "unavailable": 45 + "unavailable": 42 } } diff --git a/docs/evidence/goal-progress-live.md b/docs/evidence/goal-progress-live.md index 7ddca57..d04c4db 100644 --- a/docs/evidence/goal-progress-live.md +++ b/docs/evidence/goal-progress-live.md @@ -1,15 +1,15 @@ # Zagkit live progress snapshot -- Date: 2026-08-06T23:02:37-07:00 +- Date: 2026-08-09T13:03:05-07:00 - Scope: /home/micah/Desktop/Sylorlabs/zagkit - Zag toolkit version: 0.1.0-experimental.0 - Compiler commit: 43870455a07bf8e7d4adf38fad807fe3baee4e26 ## Goal checklist -- Total items: 97 -- Completed: 28 -- Blocked: 69 +- Total items: 100 +- Completed: 26 +- Blocked: 74 ### Blocked checklist items (unchecked in GOAL.md) @@ -21,6 +21,7 @@ - G1-CONCURRENCY - G1-DARWIN - G1-DYNAMIC-LOAD +- G1-FFI-LIFETIMES - G1-IOS - G1-JNI - G1-LINUX-ARM64 @@ -28,6 +29,7 @@ - G1-PACKAGES - G1-RELOAD - G1-RESOURCES +- G1-RUNTIME-RESOURCES - G1-SOURCE-FIRST - G1-WINDOWS - G3-ASSET-PIPELINE @@ -50,12 +52,16 @@ - G4-INPUT - G4-INSPECTORS - G4-PREVIEW +- G4-TALKBACK-ACTIONS +- G4-TALKBACK-INSPECT +- G4-TALKBACK-PIXELS - G5-ATSPI - G5-LINUX-CPU - G5-LINUX-FIDELITY - G5-LINUX-GPU - G5-LINUX-PACKAGE - G5-LINUX-POLISH +- G5-SHOWCASE-CONFORMANCE - G5-WAYLAND - G5-X11 - G6-ACCESSIBILITY @@ -63,7 +69,6 @@ - G6-AUTOMATION - G6-DENSE-UI - G6-DESIGN -- G6-INVENTORY - G6-MATERIALS - G6-PERFORMANCE - G6-POLISH @@ -95,20 +100,17 @@ - Required families: linux, macos, windows, ios, android - Total capability slots: 45 -- Unavailable capability slots: 45 +- Unavailable capability slots: 42 ### Not-ready capability blockers ``` -platform_shell:Wayland and X11 shells are not implemented. -cpu_renderer:A headless rectangle CPU-oracle subset exists, but full display-list coverage and Linux surface presentation are not implemented. gpu_transport:No Zagkit Linux GPU transport exists or has device evidence. text_input:No Linux IME bridge or editing engine exists. accessibility:No AT-SPI adapter exists or has assistive technology evidence. clipboard_drag_drop:Clipboard and drag and drop seams are not implemented. multi_window:Window, monitor, and scale lifecycle support is not implemented. packaging:No installable Linux artifact or packaging gate exists. -auto_backend_selection:There are no eligible Zagkit backends for .auto to select. platform_shell:The AppKit shell and Darwin target are not implemented. cpu_renderer:A platform-independent headless rectangle CPU-oracle subset exists, but full display-list coverage and macOS presentation are not implemented. gpu_transport:No Zagkit Metal transport exists or has device evidence. diff --git a/docs/evidence/performance-chart-2026-08-09.md b/docs/evidence/performance-chart-2026-08-09.md index e18c33a..7005433 100644 --- a/docs/evidence/performance-chart-2026-08-09.md +++ b/docs/evidence/performance-chart-2026-08-09.md @@ -4,42 +4,108 @@ This checkpoint records one bounded component contract. It does not claim a general chart suite, native accessibility, a selected visual direction, or production polish. -## Focused result +## Current verification status -- Strict executable contract: `Performance chart contract: pass=18 fail=0`. -- Strict hosted-composition contract: - `Performance chart host contract: pass=9 fail=0`. -- Retained evidence hash: `1042463816`. -- Deterministic Canvas-only CPU surface hash: `1813314353`. -- The same spec rebuilt with identical display-list, semantic, typography-slot, - hit-tree, and CPU identity. +The previous retained and CPU hashes are intentionally retired because the +chart display list now contains role-specific marker geometry and explicit +legend state treatments. Reusing those hashes would certify an obsolete image. -## Proved surface +Focused command: -The executable scene contains three named four-point performance series, seven -ticks per axis, an 8 ms baseline, a 16 ms deadline, line ribbons, scatter -marks, a Flex-positioned legend, selected/focused state, available state, and -disabled state. Semantic tokens own every color, radius, elevation, type, and -spacing role. +```sh +ZNC=/tmp/znc-resource-flow-stage6 ./tools/test-performance-chart.sh +``` + +The Stage 6 compiler has SHA-256 +`74868c0f1e61c978afaf1daf97bf2dea20fd357cd8bed75200d4b816a661501d`. +The strict command reached typed declaration analysis with no parse, import, +name, or ordinary type diagnostic, then mandatory ownership checking failed +with 713 `E0204` diagnostics before binary emission. The exact family totals +are: 427 owners falsely reported as neither released, moved, nor returned; 117 +false read/write-after-move reports; 50 false argument-after-move reports; 29 +false named-owner requirements at existing `@consumes` calls; 15 false +release-after-move or partial-path reports; 15 false initialization-source +reports; 15 false `ArrayList` element-move reports; 12 false partial-path +release reports; 11 false empty-move reports; seven false unavailable move +sources; five borrowed-view escapes; four scope-end ownership reports; four +borrowed-value escapes; one return-borrow report; and one live-overwrite report. + +The complete output is retained locally at +`/tmp/zagkit-performance-chart-stage6-strict.log`. Because the first component +contract did not compile, neither that binary nor the hosted contract ran. +This is a fail-closed upstream Zag result, not chart evidence. The canonical +checker is being fixed in `/home/micah/Desktop/Sylorlabs/zag`; Zagkit does not +weaken the contract or add ownership annotations around a broken checker. No +new executable pass count, retained hash, CPU hash, hosted result, or screenshot +is claimed until the next strict compiler gate reaches runtime. + +Static review after the Stage 6 attempt passed `git diff --check` across the +component, both focused contracts, and both chart documents. It also confirmed +the closed four-state enum, non-ready series rejection, empty-only axis +preservation, loading/error live-region mapping, non-ready legend/table +suppression, policy-specific hit counts, and absence of local color literals. +These are source invariants only; they do not replace executable evidence. + +## Proved by the source contract + +The bounded scene defines three named four-point performance series, seven +ticks per axis, an 8 ms baseline, a 16 ms deadline, retained line ribbons, +role-specific marks, and one Flex-positioned legend. Its series system is +closed and semantic: + +- `reference` maps to the primary chart color and circle marker; +- `comparison` maps to the secondary chart color and rounded-square marker; +- `diagnostic` maps to the tertiary chart color and lozenge marker. + +Roles cannot repeat within a chart and role/color mismatches fail before any +retained output. The scene covers selected plus focused, hovered, and disabled +legend entries. A separate style contract covers the default state and checks +all five treatments from the same resolver. + +The component now has a closed `ready`, `loading`, `empty`, and `error` content +state contract. Ready retains the exact series/table behavior above. Every +non-ready state requires zero series and publishes zero table dimensions, +legend nodes, series hits, focus order, and actions. Loading and error also +require zero axis ticks. Empty axes are retained only through the explicit +`preserve_empty_axes` flag and omit baseline/deadline references. Invalid +combinations fail before retained display, semantics, or hit mutation. + +Loading, empty, and error resolve a retained status artifact for panel, +indicator, name, value, and optional loading-track geometry. Loading publishes +a polite `progress` status, empty a polite named zero-data status, and error an +assertive failure status. Accent, chart-axis, error, surface, and text colors +all come from semantic tokens. The visual status strings and semantic +name/value deliberately carry the same truth; no sample points are synthesized. + +Tick, grid, reference-line, text-slot, and semantic placement consume shared +geometry records. Legend Flex runs once; its resolved item, focus, marker, +text, and selection bounds are stored in the artifact and reused by display, +hit testing, semantics, and typography composition. The parallel semantic tree names both axes and units, references, all ticks, -all series, and a 5 by 4 data table. Talkback activates and focuses the selected -series at stable ID `7100`, rejects disabled series `7102`, queries an exact -table cell by derived stable ID, and keeps pixel fallback disabled. +all series, and a 5 by 4 data table. The focused contract checks every header +and sample cell against the plotted source, queries named Talkback selected, +focused, and disabled state bits, and keeps pixel fallback disabled. -Twenty-four owned typography slots cover the title, four axis name/unit roles, +Twenty-four owned ready typography slots cover the title, four axis name/unit roles, fourteen tick labels, three legend names, baseline, and deadline. Each slot records exact bounds, stable semantic ID, type token, and color token. +Loading, ordinary empty, and error own three slots (title plus two status +strings); the seven-by-seven meaningful-empty-axis fixture owns 21. + +## Hosted boundary The hosted contract contributes the same built artifact beneath explicit caller -semantic and hit parents. It preserves all chart-local IDs and child parentage, +semantic and hit parents. It preserves chart-local IDs and child parentage, offsets focus order through a caller-selected collision-checked range, stages display ownership, and fails atomically on duplicate IDs or missing parents. +For the focused fixture, ready contribution is exactly 52 semantic nodes and +one read-only or four actionable hits. Loading, ordinary empty, and error are +exactly three semantic nodes and one hit; meaningful empty axes are 21 and one. +A duplicate status ID is preflighted atomically with no destination mutation. -## Honest boundary - -The CPU hash covers Canvas-owned visual geometry only. The current component -does not itself draw glyphs; a host must render every text slot through Zagkit -Text. Consequently this checkpoint does not include a screenshot and does not -answer native/showcase typography polish. A later composed showcase must render -all twenty-four slots before it can serve as visual chart evidence. +The CPU oracle covers Canvas-owned geometry only. The component does not itself +draw glyphs; a host must render every text slot through Zagkit Text. Therefore +this checkpoint includes no screenshot and makes no native typography-polish +claim. A composed showcase must render all twenty-four slots before it can be +used as visual chart evidence. diff --git a/docs/rfcs/0003-text-semantics-and-input.md b/docs/rfcs/0003-text-semantics-and-input.md index e60d3da..9e312c3 100644 --- a/docs/rfcs/0003-text-semantics-and-input.md +++ b/docs/rfcs/0003-text-semantics-and-input.md @@ -21,6 +21,9 @@ replacement presented as exact typography. Every component produces a parallel `SemanticsNode` tree. Nodes carry stable identity, role, name, description, value, state, actions, focus order, live region behavior, selection, ranges, text navigation, bounds, and relationships. +Actual focus is explicit state, distinct from focus order and focus-ring +visibility. A valid tree exposes at most one enabled, visible, focusable node as +focused; adapters and automation never infer focus from paint. The semantics tree drives native accessibility adapters, automation, semantic tests, and the inspector. Accessibility does not scrape pixels or infer meaning diff --git a/docs/showcase/component-state-gallery.md b/docs/showcase/component-state-gallery.md new file mode 100644 index 0000000..d756911 --- /dev/null +++ b/docs/showcase/component-state-gallery.md @@ -0,0 +1,213 @@ +# Component state gallery + +Status: `experimental` + +`ComponentStateGallery` is a reusable headless conformance artifact for +Zagkit's canonical component hierarchy and interaction language. It proves the +rules that a design-system showcase must make inspectable: tier provenance, +state variants, non-color treatments, semantic truth, stable Talkback IDs, and +one shared Flex placement graph. + +This artifact is deliberately separate from the Linux preview composition. +Consumers can inspect or host it without teaching a one-off screen how each +component should draw itself. + +## Fixture matrix + +Every specimen is emitted through its first-party component implementation. +State captions and control labels use the first-party `Text` renderer; the +gallery contains no placeholder bars, decorative status dots, fake icons, or +hand-drawn substitute controls. + +| Family | Fixtures | Contract under test | +|---|---:|---| +| `Surface` hierarchy | 4 | `base`, `panel`, `raised`, and `overlay` material and elevation provenance | +| Actionable `Surface` | 8 | `rest`, `hover`, `focus`, `pressed`, `selected`, `disabled`, `loading`, and `error` | +| Secondary `Button` | 8 | the same eight canonical states, including real loading-content substitution | +| `NavigationItem` | 6 | `rest`, `hover`, `focus`, `pressed`, `selected`, and `disabled` | +| `SegmentedControl` | 4 options | selected, disabled, resting, and runtime focus-visible behavior in one real control | + +The four hierarchy Surfaces are read-only evidence. Their canonical Surface +display and group semantics are retained, while the component's disabled +inspection-only scratch hit is intentionally not published into the gallery. +They therefore expose no gallery hit and no semantic action. The state +Surfaces, Buttons, NavigationItems, and SegmentedControl are explicit +interactive specimens with canonical hits and actions backed by +`ComponentStateGalleryRuntimeState`. Every accepted activation increments +`activation_count`, records `last_activated`, and advances `revision`. Disabled +and loading specimens remain discoverable by ID but have no semantic action, +have disabled hit nodes, and are rejected by the reducer without changing an +outcome. + +## Visible state is semantic state + +The matrix does not ask color alone to explain the system. Across the canonical +components, the contract checks the state model, resolved token changes, +semantic flags, action availability, and each non-color treatment the +component actually defines, including: + +- Surface hover marker and Button/Navigation hover token resolution; +- keyboard-visible focus ring; +- depressed pressed chrome; +- persistent selected marker; +- disabled treatment and unavailable action; +- loading progress substitution; and +- recoverable-error treatment. + +This is not a claim that every component draws every listed geometry. For +example, Button hover is a resolved fill/border change rather than Surface's +trailing marker. The test verifies each family against its own first-party +state contract instead of inventing a showcase-only indicator. + +Each fixture has an uppercase visible `Text` caption such as `HOVER` or +`DISABLED`. Its component semantic value also describes the fixture state. The +control label remains the control's accessible name, so a screen reader does +not have to infer the state from paint or from a nearby decorative object. + +A retained `SemanticsTree` permits at most one live focused node. Consequently, +the Surface, Button, and NavigationItem focus examples are explicitly labelled +*focus styling snapshots*: their first-party focus rings are retained, but +their hosted semantic `focused` field is cleared and their value is +`Focus fixture`. The native-default runtime starts with `focused = none`, so +the complete tree reports zero live focus. Pointer or keyboard reduction may +assign one enabled component or segment; only that exact ID then publishes +`focused = 1`. This preserves visual comparison without fabricating native +focus or publishing an impossible multi-focus accessibility tree. + +The segmented model is not a row of tags. Option zero is selected, option two +is disabled, and options one and three are enabled at rest. A keyboard focus +transition to option one followed by canonical `key_next` skips the disabled +option, selects and focuses option three, and records option three as an +observable activation. + +Semantic insertion follows visual reading order: title, section heading, that +section's captions and controls, then the next heading. The five headings are +not emitted as a detached block ahead of the fixtures. + +## Runtime state and reducers + +The gallery does not infer interaction from its paint. Hosts own a +`ComponentStateGalleryRuntimeState` containing `hovered`, `pressed`, `focused`, +`focus_visible`, the canonical `SegmentedControlModel`, `last_activated`, +`activation_count`, and `revision`. Start native hosting with +`component_state_gallery_runtime_state_default()`, send typed events through +`component_state_gallery_reduce`, then rebuild with +`component_state_gallery_build_with_state`. + +`component_state_gallery_runtime_state_valid` checks that transient and focus +IDs name enabled gallery controls, that segment IDs agree with the nested +segmented model, and that focus visibility cannot exist without focus. +`component_state_gallery_runtime_state_hash` is deterministic and is included +in the artifact evidence hash. Accepted transitions advance revision exactly +once; rejected disabled, loading, read-only, or invalid-target transitions +leave the input state unchanged. + +`component_state_gallery_build_frozen_conformance` exists only for a fixed +inspection fixture that preserves the earlier focus-visible segmented example. +It is marked by `artifact.frozen_conformance = 1`. Native routes must use the +zero-focus default or application-owned runtime state, never the frozen wrapper. + +## One Flex authority + +`component_state_gallery_layout` is the only placement authority. An outer +Flex column owns the title and five sections; nested Flex columns own headings +and content; repeated rows own fixture cells; each cell owns its caption and +component rectangle. Component display bounds, hit bounds, semantic bounds, +and Talkback geometry all come from those recorded placements. + +The graph uses named `FlexSpacingToken` values and supports compact, standard, +and touch density plus LTR and RTL direction. Its exact useful bounds are: + +| Density | Minimum width | Required height | +|---|---:|---:| +| compact | `1044` | `1119` | +| standard | `1072` | `1192` | +| touch | `1100` | `1265` | + +The default contract uses `1440 x 1280`. RTL mirrors gallery Flex row order +without changing any ID. `SegmentedControlSpec` does not yet expose layout +direction, so its internal option geometry remains LTR; this artifact does not +claim otherwise. The artifact intentionally has no responsive column-count +mode: it keeps four columns for hierarchy, Surface, and Button rows and three +columns for NavigationItem rows, then fails closed below the useful width. +Hosts that offer a narrower viewport must use a canonical scroll or viewport +primitive rather than silently shrinking targets or inventing another layout. + +One logical unit below either minimum fails with `layout_overflow` before any +display, hit, or semantic contribution is published. + +## Stable identity and Talkback + +Given root key `R`, child values are derived as `R.value * 10000 + offset` and +preserve `R.generation`: + +| Node | Offset | +|---|---:| +| title | `100` | +| section heading `i` | `200 + i` | +| hierarchy Surface `i` | `1000 + i * 10` | +| hierarchy caption `i` | `1001 + i * 10` | +| state Surface `i` | `2000 + i * 10` | +| state caption `i` | `2001 + i * 10` | +| Button `i` | `3000 + i * 10` | +| Button caption `i` | `3001 + i * 10` | +| NavigationItem `i` | `4000 + i * 10` | +| NavigationItem caption `i` | `4001 + i * 10` | +| SegmentedControl group | `5000` | +| segment option `i` | `5010 + i` | + +The same component ID owns display operations, the hit node, the semantic node, +and Talkback query/action lookup. Captions have their own stable IDs. The +contract proves that Talkback resolves and emits an action for an enabled +specimen, queries selected and focused flags, and rejects disabled or read-only +activation without falling back to pixel coordinates. Emitting an action is +not itself an outcome: the host feeds the resolved ID through the gallery +reducer, retains the resulting state, and rebuilds. Tests prove that this path +changes activation evidence rather than claiming completion from dispatch +alone. + +## Retained output and failure behavior + +A successful zero-focus default gallery and frozen conformance fixture both +have the following exact retained shape: + +- 61 real `Text` artifacts and 61 OpenType path resources; +- 407 display operations in one sealed immutable `DisplayList`; +- 27 hit nodes; +- 64 semantic nodes, of which 32 are independently named text nodes; and +- deterministic display, hit, Talkback, CPU-raster, and aggregate evidence + hashes for identical inputs. + +Component output is first built into isolated scratch display, hit, and +semantic trees. Hosting preflights the expected shape, reparents semantics under +the gallery root, and checkpoints all three retained destinations. Duplicate +IDs or any display, hit, or semantic error roll every destination back to its +prior length, revision, hash, and error state. The SegmentedControl plus its +four Text labels is one larger transaction: a late label/resource failure also +releases previously contributed path payloads and restores counters and focus +state. Missing fonts, invalid root IDs, and undersized layouts similarly fail +before partial output is exposed. + +Always call `component_state_gallery_free` on successful and failed artifacts. +The caller continues to own and eventually free the borrowed `OpenTypeFace`. + +## Verification + +Run the focused contract with a Zag-owned OpenType input: + +```sh +tools/test-component-state-gallery.sh +``` + +The runner compiles with strict resource analysis and no `zagd` or foreground +cache. The contract covers the complete fixture matrix, token and treatment +truth, exact geometry, zero native-default focus, one real runtime focus owner, +observable enabled activations, rejected unavailable actions, +SegmentedControl input reduction, Talkback action dispatch, touch-density RTL, +deterministic CPU rasterization, atomic scratch-host rollback, invalid runtime +truth, invalid identity, missing font, and one-unit overflow boundaries. + +This artifact is headless proof, not Linux platform certification. It does not +claim native event delivery, assistive-technology bridging, GPU equivalence, +animation behavior, international bidi shaping, or final visual-direction +approval. Those require their own live platform and visual gates. diff --git a/docs/showcase/token-inspector.md b/docs/showcase/token-inspector.md new file mode 100644 index 0000000..e7fc8f5 --- /dev/null +++ b/docs/showcase/token-inspector.md @@ -0,0 +1,36 @@ +# Experimental native token inspector + +The Linux showcase's `Inspect tokens` control is a real retained interaction, +not a screenshot annotation. Activating stable `NodeKey` `20302:0` rebuilds the +composition with canonical overlay Surface `20800:0`; activating it again +closes the overlay. The Button publishes selected state and the visible label +`Close inspector` while open. + +The overlay currently inspects retained main Surface `20300:0`. Ten visible +Text nodes, `20801:0` through `20810:0`, publish the same content to semantics +and Zagkit Talkback: + +- inspector title and selected `NodeKey`; +- canonical component and hierarchy tier; +- material and fill token IDs; +- visible type token IDs; +- Flex spacing token ID; +- radius and elevation token IDs; +- current theme, scale, and density inputs; +- the material's exact deterministic CPU fallback reason. + +The overlay uses the canonical `SurfaceTier.overlay` path. Its display, +semantic, and hit bounds come from `linux_preview_token_inspector_bounds`; the +control is disabled with no action or focus order when the minimum 320 by 340 +logical-pixel panel cannot fit. The Linux interaction runner opens it by ID, +captures it, requires the frame hash to change, and closes it by the same ID. + +## Honest boundary + +This slice proves a stateful control, visible provenance, overlay hierarchy, +stable IDs, and compact fail-closed behavior. It does not yet support choosing +an arbitrary rendered node. It also does not yet expose resolved numeric token +values, the complete interaction-state trace, or one serialized Flex trace +shared by all inspectors. Those remain required by +[showcase conformance](../design/showcase-conformance.md) and keep the inspector +and gallery milestones open. diff --git a/docs/showcase/typography-specimen.md b/docs/showcase/typography-specimen.md new file mode 100644 index 0000000..0d66a53 --- /dev/null +++ b/docs/showcase/typography-specimen.md @@ -0,0 +1,230 @@ +# Typography specimen + +Status: `experimental` + +`TypographySpecimen` is a reusable headless conformance artifact for Zagkit's +type system. It is intentionally more demanding than a static typography card: +the same artifact owns immutable path rendering, semantic text, stable Talkback +IDs, Flex geometry, adaptive evidence, and explicit failure state. + +It does not select Zagkit's 1.0 visual direction. It proves that the current +semantic system can apply a small set of named rules consistently. + +## What it proves + +The artifact emits thirteen rows in three controlled sections: + +| Rows | Variable under test | Fixed inputs | +|---|---|---| +| `0..6` | `display`, `title`, `heading`, `body`, `label`, `caption`, `code` | regular face and `color.text.primary` | +| `7..9` | caller-declared light, regular, and bold faces | `type.body`, identical text, and `color.text.primary` | +| `10..12` | primary, secondary, and disabled text colors | regular face, identical text, and `type.body` | + +This isolation matters. A showcase cannot prove a type ramp if size, weight, +and color all change at once. Each section changes one design-system axis and +records the rest as inspectable metadata. + +`type.code` is included because it is part of the canonical seven-role ramp. +The current `Text` contract has no font-family routing, so this row uses the +regular face and records `code_family_routing_available = 0`; it proves the +named size role, not a monospace-family implementation. + +The successful artifact contains: + +- 13 `TypographySpecimenRow` records; +- 26 real `Text` artifacts contributed as OpenType path resources, one label + and one sample per row; +- 130 retained display operations, because each `Text` contributes the exact + `save / transform / clip / draw_path / restore` envelope; +- 40 semantic nodes: one specimen group, thirteen row groups, thirteen labels, + and thirteen samples; and +- one sealed `DisplayList`, one `talkback_tree_hash`, and one aggregate evidence + hash covering tokens, geometry, font-path evidence, semantics, and display. + +There are no placeholder bars, handcrafted SVG glyphs, synthetic bold effects, +or decorative dots standing in for typography. + +## Public construction and cleanup + +Create a default spec, customize its samples or adaptive context, then build it +with three parsed Zag-owned `OpenTypeFace` values: + +```zag +let spec: TypographySpecimenSpec = typography_specimen_spec( + node_key_scoped(7300, 4), + rect(0, 0, 1120 * unit_scale(), 720 * unit_scale())); +spec.density = FlexDensity.standard; +spec.direction = LayoutDirection.ltr; +spec.text_scale = unit_scale(); + +let artifact: TypographySpecimenArtifact = typography_specimen_build( + spec, &light_face, ®ular_face, &bold_face); +if (typography_specimen_valid(&artifact) == 0) { + // Inspect artifact.error and the subordinate diagnostic fields. +} + +typography_specimen_free(&artifact); +``` + +The three faces are borrowed for the build call. The artifact owns its copied +path resources, semantic strings, row records, and display operations. Call +`typography_specimen_free` on both successful and failed artifacts. The caller +continues to own and eventually free each `OpenTypeFace`. + +## Hosting in a larger composition + +Build the artifact with the final content rectangle, then host it beneath an +existing semantic group without rebuilding or copying its geometry in the +consumer: + +```zag +let hosted: TypographySpecimenHostResult = + typography_specimen_contribute_hosted( + &artifact, + typography_specimen_host_spec(existing_content_group_id), + &composition_display, + &composition_semantics); +``` + +On success the contribution adds exactly 130 display operations, 26 path +resources, and 40 semantic nodes. It reparents only the specimen root to the +declared existing parent. All row, label, and sample IDs, parent links, bounds, +display owners, and resource IDs remain unchanged. There is no hit-tree +contribution because the specimen is intentionally read-only. + +The destination display must be valid and unsealed. The semantic parent must be +the semantic root or already exist in the destination tree. The API preflights +semantic IDs, resource IDs, and destination resource capacity, stages a full +copy of the current display, then appends semantics under a checkpoint. Missing +parents, duplicate semantic IDs, resource collisions, resource limits, display +errors, and semantic errors leave both caller destinations unchanged. The +source artifact remains immutable and reusable after either success or failure. + +## Stable identity and Talkback + +The caller supplies one positive root `NodeKey`. Zagkit derives every child ID +without pixel inference: + +```text +root.value * 1000 + 100 + row * 10 -> row group +root.value * 1000 + 101 + row * 10 -> row label +root.value * 1000 + 102 + row * 10 -> row sample +``` + +The root generation is preserved for every child. These IDs are unchanged by +density, text scale, or layout direction. `TypographySpecimenRow` records the +same rectangles used by Flex, `Text`, semantics, and Talkback queries. + +Specimen nodes are intentionally non-actionable. Talkback can discover and +query them by ID, but click fails with `action_unavailable`. The disabled +contrast sample also carries semantic disabled state; it is not merely dimmer +paint. + +## Flex spacing and adaptive behavior + +All repeated geometry resolves through Flex tokens: + +| Purpose | Token | +|---|---| +| outer inset | `FlexSpacingToken.large` | +| row gap | `FlexSpacingToken.small` | +| row internal padding | `FlexSpacingToken.small` | +| label-to-sample gap | `FlexSpacingToken.large` | + +`FlexDensity.compact`, `standard`, and `touch` resolve those tokens through the +canonical density function. An outer column owns row placement. Each row owns +an inner two-column Flex layout. A layout that cannot honor minimum content +width or required row height returns `layout_overflow`; it does not silently +overlap or compress text into unreadable geometry. + +At standard density and `1x` text, `typography_specimen_required_height` +returns `666 px`. The structural two-column minimum is `344 px`, but the +default display sample in DejaVu Sans needs a `705 px` useful width after the +current equal-shrink Flex allocation. Therefore the existing `1120 x 720` +medium content region fits the default specimen. The two-step large-text +fixture requires `814.5 px` of height and is tested in the `1360 x 900` +expanded reference window. Custom strings and fonts can require more width; +`TextError.overflow` remains the authoritative fail-closed result. + +RTL mirrors the two logical columns and their edge alignment while preserving +the same IDs. This is **RTL layout evidence only**. The current `Text` component +uses nominal LTR glyph runs, so the artifact records: + +- `rtl_layout_applied = 1` when requested; +- `nominal_ltr_text_only = 1`; +- `bidi_shaping_available = 0`; and +- `font_fallback_available = 0`. + +Do not use this specimen to claim Arabic, Hebrew, mixed-direction shaping, or +font fallback support. Those remain upstream text-engine work. + +## Large text + +The current text engine has named semantic sizes but no continuous font-scale +primitive. The specimen therefore uses an explicit, deterministic semantic-role +substitution policy: + +| Effective scale | Substitution | +|---|---| +| `1.0 .. <1.25` | requested role | +| `1.25 .. <1.75` | one step larger | +| `1.75 .. 4.0` | two steps larger | + +Roles cap at `type.display`. For example, two steps map `body -> title` and +`caption -> body`. Flex recomputes row height from the resolved role, while IDs +remain stable. Both requested and resolved roles are stored in every row. + +This policy is real adaptive behavior, but it is not continuous scaling. The +artifact keeps `continuous_text_scaling_available = 0` so screenshots and +automation cannot overstate the implementation. + +## Weight truth + +Zagkit does not synthesize bold by stroking or offsetting glyphs. The caller +must provide three distinct font faces. Before emitting any semantics or display +operations, the build renders the same text, size, ID, and bounds through each +face and hashes the encoded path payload. All three hashes must be nonzero and +pairwise different. Otherwise the build returns `weight_not_visible` with an +empty showcase output. + +The current OpenType parser does not yet expose or validate the `OS/2` weight +class, so `weight_metadata_verified` remains `0`. Distinct outline evidence +proves that the visual weight slots are not aliases; the caller-declared labels +remain an explicit boundary until metadata parsing lands upstream. + +## Font and text failure + +All three faces are validated before layout publication. A missing or invalid +face returns `missing_font` and identifies light (`0`), regular (`1`), or bold +(`2`) in `missing_face_index`. No rows, semantic nodes, or display operations +are emitted in that case. + +Malformed UTF-8, missing glyph coverage, invalid outlines, and line-box overflow +remain strict `Text` failures. Zagkit does not inject replacement glyphs, borrow +an OS text renderer, or silently choose a different font. + +## Verification + +Run the focused contract with the default DejaVu Sans family: + +```sh +tools/test-typography-specimen.sh +``` + +Or provide three exact TrueType files: + +```sh +tools/test-typography-specimen.sh \ + /path/to/Light.ttf /path/to/Regular.ttf /path/to/Bold.ttf +``` + +The runner rejects missing files and byte-identical face inputs before compile. +Environment overrides are also available as +`ZAGKIT_TYPOGRAPHY_LIGHT_FONT`, `ZAGKIT_TYPOGRAPHY_REGULAR_FONT`, and +`ZAGKIT_TYPOGRAPHY_BOLD_FONT`. + +The contract covers the complete semantic ramp, single-variable weight and +contrast strips, exact Flex-to-semantics geometry, stable IDs across compact RTL +and large text, Talkback queries, deterministic CPU pixels, atomic hosted +contribution and collision rollback, missing fonts, fake weight aliases, layout +overflow, and cleanup. diff --git a/error-debugging-config.json b/error-debugging-config.json new file mode 100644 index 0000000..60b2df2 --- /dev/null +++ b/error-debugging-config.json @@ -0,0 +1,189 @@ +{ + "server": { + "name": "error-debugging-mcp-server", + "version": "1.0.0", + "logLevel": "info", + "maxConnections": 10, + "timeout": 30000 + }, + "transport": { + "type": "stdio" + }, + "detection": { + "enabled": true, + "realTime": true, + "sources": { + "console": true, + "runtime": true, + "build": true, + "test": true, + "linter": true, + "staticAnalysis": true, + "ide": false, + "buildTools": true, + "processMonitor": true, + "multiLanguage": true + }, + "filters": { + "categories": [], + "severities": [], + "excludeFiles": [ + "node_modules/**", + "dist/**", + "build/**" + ], + "excludePatterns": [ + "*.min.js", + "*.map" + ] + }, + "polling": { + "interval": 1000, + "maxRetries": 3 + }, + "bufferSize": 1000, + "maxErrorsPerSession": 10000 + }, + "analysis": { + "enabled": false, + "aiEnhanced": false, + "confidenceThreshold": 0.7, + "maxAnalysisTime": 10000, + "enablePatternMatching": true, + "enableSimilaritySearch": true, + "enableRootCauseAnalysis": true, + "enableImpactPrediction": false, + "customPatterns": [], + "historicalDataRetention": 30 + }, + "debugging": { + "enabled": false, + "languages": {}, + "defaultTimeout": 30000, + "maxConcurrentSessions": 5, + "enableHotReload": false, + "enableRemoteDebugging": false, + "breakpoints": { + "maxPerSession": 50, + "enableConditional": true, + "enableLogPoints": true + }, + "variableInspection": { + "maxDepth": 10, + "maxStringLength": 1000, + "enableLazyLoading": true + } + }, + "performance": { + "enabled": false, + "profiling": { + "enabled": false, + "sampleRate": 100, + "maxDuration": 60000, + "includeMemory": true, + "includeCpu": true + }, + "monitoring": { + "enabled": true, + "interval": 5000, + "thresholds": { + "memory": 536870912, + "cpu": 80, + "responseTime": 1000 + } + }, + "optimization": { + "enableSuggestions": true, + "enableAutomaticOptimization": false, + "aggressiveness": "moderate" + } + }, + "integrations": { + "buildSystems": { + "webpack": false, + "vite": false, + "rollup": false, + "parcel": false, + "esbuild": false + }, + "testRunners": { + "jest": false, + "vitest": false, + "mocha": false, + "pytest": false, + "goTest": false, + "cargoTest": false + }, + "linters": { + "eslint": false, + "tslint": false, + "pylint": false, + "flake8": false, + "golint": false, + "clippy": false + }, + "versionControl": { + "git": false, + "enableCommitHooks": false, + "enableBranchAnalysis": false + }, + "containers": { + "docker": false, + "kubernetes": false, + "enableContainerDebugging": false + }, + "ides": { + "vscode": false, + "cursor": false, + "windsurf": false, + "augmentCode": false + } + }, + "security": { + "enableSecurityScanning": false, + "vulnerabilityDatabases": [], + "enableDependencyScanning": false, + "enableCodeScanning": false, + "reportingLevel": "medium-high", + "autoFixVulnerabilities": false, + "excludePatterns": [ + "test/**", + "tests/**" + ] + }, + "detectors": { + "enabled": true, + "realTime": true, + "sources": { + "console": true, + "runtime": true, + "build": true, + "test": true, + "linter": true, + "staticAnalysis": true, + "ide": false, + "buildTools": true, + "processMonitor": true, + "multiLanguage": true + }, + "filters": { + "categories": [], + "severities": [], + "excludeFiles": [ + "node_modules/**", + "dist/**", + "build/**" + ], + "excludePatterns": [ + "*.min.js", + "*.map" + ] + }, + "polling": { + "interval": 1000, + "maxRetries": 3 + }, + "bufferSize": 1000, + "maxErrorsPerSession": 10000 + } +} \ No newline at end of file diff --git a/examples/linux_preview.zag b/examples/linux_preview.zag index db6fab1..38b8a27 100644 --- a/examples/linux_preview.zag +++ b/examples/linux_preview.zag @@ -1,19 +1,18 @@ @import("../src/platform/linux/x11.zag") -@import("../src/showcase/linux_preview_scene.zag") +@import("../src/showcase/linux_preview_composition.zag") @import("../src/render/png_encode.zag") -fn preview_render(host: *LinuxX11Window, width: i32, height: i32, - capture_path: []u8, face: OpenTypeFace) i32 { - let list: DisplayList = linux_preview_scene_with_font(width, height, face); - if (display_list_verify(list) != DisplayError.none) { - display_list_free(&list); - _zag_eprintln("zagkit: Linux preview display list verification failed"); +fn preview_present(host: @borrows_mut *LinuxX11Window, + frame: @borrows *LinuxPreviewCompositionArtifact, + width: i32, height: i32, capture_path: []u8) i32 { + if (linux_preview_composition_valid(frame.*) == 0) { + _zag_eprintln("zagkit: retained Linux composition validation failed"); return 0; } - let raster: CpuRasterResult = cpu_rasterize(list, width, height); + let raster: CpuRasterResult = cpu_rasterize( + frame.*.display, width, height); if (raster.error != CpuRasterError.none) { cpu_raster_free(&raster); - display_list_free(&list); _zag_eprintln("zagkit: Linux preview CPU raster failed"); return 0; } @@ -28,16 +27,224 @@ fn preview_render(host: *LinuxX11Window, width: i32, height: i32, png_encode_free(&png); } cpu_raster_free(&raster); - display_list_free(&list); return presented; } +fn preview_navigation_index(id: NodeKey) i32 { + let index: i32 = 0; + while (index < linux_preview_navigation_count()) { + if (node_key_equal(id, + linux_preview_composition_nav_id(index)) != 0) { return index; } + index = index + 1; + } + return 0 - 1; +} + +fn preview_segment_index(id: NodeKey) i32 { + let index: i32 = 0; + while (index < 4) { + if (node_key_equal(id, + linux_preview_composition_segment_id(index)) != 0) { + return index; + } + index = index + 1; + } + return 0 - 1; +} + +fn preview_focus_order( + frame: @borrows *LinuxPreviewCompositionArtifact, + id: NodeKey) i64 { + let index: i32 = semantics_find_index(frame.*.semantics, id); + if (index < 0) { return 0; } + return frame.*.semantics.nodes.data[index].focus_order; +} + +fn preview_next_focus( + frame: @borrows *LinuxPreviewCompositionArtifact, + current: NodeKey, reverse: i32) NodeKey { + let current_order: i64 = preview_focus_order(frame, current); + let candidate: NodeKey = semantic_root_key(); + let candidate_order: i64 = 0; + let wrapped: NodeKey = semantic_root_key(); + let wrapped_order: i64 = 0; + let index: i32 = 0; + while (index < frame.*.semantics.nodes.len) { + let node: SemanticsNode = frame.*.semantics.nodes.data[index]; + if (node.disabled == 0 && node.focus_order > 0) { + if (reverse == 0) { + if (wrapped_order == 0 || node.focus_order < wrapped_order) { + wrapped = node.id; + wrapped_order = node.focus_order; + } + if (node.focus_order > current_order && + (candidate_order == 0 || + node.focus_order < candidate_order)) { + candidate = node.id; + candidate_order = node.focus_order; + } + } else { + if (node.focus_order > wrapped_order) { + wrapped = node.id; + wrapped_order = node.focus_order; + } + if ((current_order == 0 || node.focus_order < current_order) && + node.focus_order > candidate_order) { + candidate = node.id; + candidate_order = node.focus_order; + } + } + } + index = index + 1; + } + if (candidate_order > 0) { return candidate; } + return wrapped; +} + +fn preview_general_interactive(id: NodeKey) i32 { + return (preview_navigation_index(id) >= 0 || + node_key_equal(id, + linux_preview_composition_inspect_id()) != 0) as i32; +} + +fn preview_trace_hit(frame: @borrows *LinuxPreviewCompositionArtifact, + hit: HitResult) void { + if (hit.found == 0 || hit.node_index < 0) { + _zag_println("zagkit: hit target=none"); + return; + } + _zag_print("zagkit: hit target-id="); + _zag_print_i64(hit.id.value); + _zag_print(" enabled="); + _zag_print_i64( + frame.*.hits.nodes.data[hit.node_index].enabled as i64); + _zag_println(""); +} + +fn preview_trace_target( + frame: @borrows *LinuxPreviewCompositionArtifact, + id: NodeKey) void { + let target: TalkbackPixelFallbackTarget = + talkback_pixel_fallback_for_id(&frame.*.semantics, + frame.*.layout.viewport, id, TalkbackCommand.click, 1, 1); + if (target.status != TalkbackStatus.accepted) { return; } + _zag_print("zagkit: talkback pixel-fallback target-id="); + _zag_print_i64(id.value); + _zag_print(" x="); + _zag_print_i64(target.logical_bounds.x / unit_scale()); + _zag_print(" y="); + _zag_print_i64(target.logical_bounds.y / unit_scale()); + _zag_print(" width="); + _zag_print_i64(target.logical_bounds.width / unit_scale()); + _zag_print(" height="); + _zag_print_i64(target.logical_bounds.height / unit_scale()); + _zag_print(" scale=1/1 enabled="); + _zag_print_i64(target.action_available as i64); + _zag_println(""); +} + +fn preview_trace_targets( + frame: @borrows *LinuxPreviewCompositionArtifact) void { + preview_trace_target(frame, linux_preview_composition_nav_id(0)); + preview_trace_target(frame, linux_preview_composition_nav_id(1)); + preview_trace_target(frame, linux_preview_composition_nav_id(3)); + preview_trace_target(frame, linux_preview_composition_segment_id(2)); + preview_trace_target(frame, linux_preview_composition_inspect_id()); + _zag_print("zagkit: frame selected-segment="); + _zag_print_i64(frame.*.segmented_model.selected_index as i64); + _zag_print(" roving-segment="); + _zag_print_i64(frame.*.segmented_model.roving_index as i64); + _zag_print(" has-focus="); + _zag_print_i64(frame.*.segmented_model.has_focus as i64); + _zag_print(" focus-visible="); + _zag_print_i64(frame.*.segmented_model.focus_visible as i64); + _zag_print(" hovered-segment="); + _zag_print_i64(frame.*.segmented_model.hovered_index as i64); + _zag_print(" token-inspector-open="); + _zag_print_i64(frame.*.token_inspector_open as i64); + _zag_print(" hovered-id="); + _zag_print_i64(frame.*.interaction.hovered.value); + _zag_print(" pressed-id="); + _zag_print_i64(frame.*.interaction.pressed.value); + _zag_print(" focused-id="); + _zag_print_i64(frame.*.interaction.focused.value); + _zag_print(" general-focus-visible="); + _zag_print_i64(frame.*.interaction.focus_visible as i64); + _zag_print(" evidence-hash="); + _zag_print_i64(frame.*.evidence_hash); + _zag_println(""); + _zag_print("zagkit: route selected-navigation="); + _zag_print_i64(frame.*.selected_navigation as i64); + _zag_print(" typography-composed="); + _zag_print_i64(frame.*.typography_composed as i64); + _zag_print(" typography-rows="); + _zag_print_i64(frame.*.typography_rows_composed as i64); + _zag_println(""); + if (frame.*.unsupported_size_composed != 0) { + _zag_print("zagkit: unsupported-size target-id="); + _zag_print_i64(linux_preview_composition_unsupported_id().value); + _zag_println(" minimum=480x360"); + } +} + +fn preview_event_hit( + frame: @borrows *LinuxPreviewCompositionArtifact, + event: LinuxX11Event) HitResult { + return hit_test_including_disabled(frame.*.hits, + (event.x as i64) * unit_scale(), + (event.y as i64) * unit_scale()); +} + +fn preview_segment_model_equal( + left: SegmentedControlModel, right: SegmentedControlModel) i32 { + return (left.segment_count == right.segment_count && + left.selected_index == right.selected_index && + left.roving_index == right.roving_index && + left.has_focus == right.has_focus && + left.focus_visible == right.focus_visible && + left.hovered_index == right.hovered_index && + left.pressed_index == right.pressed_index && + left.enabled_mask == right.enabled_mask) as i32; +} + +fn preview_apply_segment_event( + model: @borrows_mut *SegmentedControlModel, + event: SegmentedControlEvent) SegmentedControlTransition { + let transition: SegmentedControlTransition = + segmented_control_reduce(model.*, event); + if (transition.rejected == 0) { model.* = transition.model; } + return transition; +} + +fn preview_trace_selection(transition: SegmentedControlTransition, + target_id: NodeKey, input_trace: i32) void { + if (transition.selection_changed == 0 || input_trace == 0) { return; } + _zag_print("zagkit: segment selection target-id="); + _zag_print_i64(target_id.value); + _zag_print(" selected-index="); + _zag_print_i64(transition.selected_index as i64); + _zag_println(""); +} + fn preview_clamp_dimension(value: i32, minimum: i32, maximum: i32) i32 { if (value < minimum) { return minimum; } if (value > maximum) { return maximum; } return value; } +fn preview_load_face(path: []u8) OpenTypeFace { + let bytes: []u8 = _zag_read_file(path); + if (bytes.len <= 0) { + if (bytes.len >= 0) { _zag_free(_zag_slice_ptr(bytes)); } + let unavailable: OpenTypeFace = opentype_face_empty(); + unavailable.error = OpenTypeError.empty; + return unavailable; + } + let face: OpenTypeFace = opentype_parse(bytes); + _zag_free(_zag_slice_ptr(bytes)); + return face; +} + fn main() i32 { let capture_once: i32 = 0; let capture_path: []u8 = ""; @@ -50,26 +257,31 @@ fn main() i32 { capture_path = _zag_arg(2); } - let font_path: []u8 = _zag_getenv("ZAGKIT_FONT_FILE"); - if (font_path.len == 0) { - _zag_eprintln("zagkit: ZAGKIT_FONT_FILE is required for truthful native typography"); + let light_font_path: []u8 = _zag_getenv("ZAGKIT_FONT_LIGHT_FILE"); + let regular_font_path: []u8 = _zag_getenv("ZAGKIT_FONT_FILE"); + let bold_font_path: []u8 = _zag_getenv("ZAGKIT_FONT_BOLD_FILE"); + if (light_font_path.len == 0 || regular_font_path.len == 0 || + bold_font_path.len == 0) { + _zag_eprintln("zagkit: light, regular, and bold font files are required for truthful native typography"); return 3; } - let font_bytes: []u8 = _zag_read_file(font_path); - if (font_bytes.len <= 0) { - _zag_eprintln("zagkit: configured font bytes are unavailable"); - return 3; - } - let face: OpenTypeFace = opentype_parse(font_bytes); - _zag_free(_zag_slice_ptr(font_bytes)); - if (face.error != OpenTypeError.none) { - _zag_eprintln("zagkit: configured font failed owned OpenType validation"); + let light_face: OpenTypeFace = preview_load_face(light_font_path); + let face: OpenTypeFace = preview_load_face(regular_font_path); + let bold_face: OpenTypeFace = preview_load_face(bold_font_path); + if (light_face.error != OpenTypeError.none || + face.error != OpenTypeError.none || + bold_face.error != OpenTypeError.none) { + _zag_eprintln("zagkit: configured font weights failed owned OpenType validation"); + opentype_face_free(&bold_face); opentype_face_free(&face); + opentype_face_free(&light_face); return 3; } let wayland_available: i32 = (_zag_getenv("WAYLAND_DISPLAY").len > 0) as i32; - let host: LinuxX11Window = linux_x11_open(1120, 720, + let input_trace: i32 = (_zag_getenv("ZAGKIT_INPUT_TRACE").len > 0) as i32; + let host: LinuxX11Window = linux_x11_open( + linux_preview_reference_width(), linux_preview_reference_height(), "Zagkit — Linux Preview", wayland_available); _zag_print("zagkit: requested backend=auto active backend="); _zag_println(platform_backend_name(host.capabilities.active)); @@ -80,32 +292,390 @@ fn main() i32 { if (host.open == 0) { _zag_eprintln("zagkit: native Linux window unavailable; capability report remains fail-closed"); linux_x11_close(&host); + opentype_face_free(&bold_face); opentype_face_free(&face); + opentype_face_free(&light_face); return 3; } - if (preview_render(&host, 1120, 720, capture_path, face) == 0) { + let selected_navigation: i32 = linux_preview_initial_navigation(); + let segmented_model: SegmentedControlModel = + linux_preview_composition_segmented_model_for(0); + let token_inspector_open: i32 = 0; + let interaction: LinuxPreviewInteractionState = + linux_preview_interaction_state(); + let frame: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_fonts_interaction_state( + linux_preview_reference_width(), linux_preview_reference_height(), + &light_face, &face, &bold_face, + selected_navigation, segmented_model, token_inspector_open, + interaction); + if (preview_present(&host, &frame, + linux_preview_reference_width(), linux_preview_reference_height(), + capture_path) == 0) { + linux_preview_composition_free(&frame); linux_x11_close(&host); + opentype_face_free(&bold_face); opentype_face_free(&face); + opentype_face_free(&light_face); return 4; } + if (input_trace != 0) { preview_trace_targets(&frame); } if (capture_once != 0) { + linux_preview_composition_free(&frame); linux_x11_close(&host); + opentype_face_free(&bold_face); opentype_face_free(&face); + opentype_face_free(&light_face); return 0; } + let runtime_error: i32 = 0; + let pointer_pressed: NodeKey = hit_root_key(); while (host.open != 0) { - let event: LinuxX11Event = linux_x11_next_event(&host); - if (event.kind == LinuxX11EventKind.close) { host.open = 0; } - else if (event.kind == LinuxX11EventKind.expose) { - _ = preview_render(&host, host.width, host.height, "", face); - } else if (event.kind == LinuxX11EventKind.resize) { - let next_width: i32 = preview_clamp_dimension(event.width, 480, 1920); - let next_height: i32 = preview_clamp_dimension(event.height, 320, 1200); - _ = preview_render(&host, next_width, next_height, "", face); + let target_width: i32 = host.width; + let target_height: i32 = host.height; + let needs_present: i32 = 0; + let needs_rebuild: i32 = 0; + let draining: i32 = 1; + while (draining != 0 && host.open != 0) { + let event: LinuxX11Event = linux_x11_next_event(&host); + if (event.kind == LinuxX11EventKind.close) { + host.open = 0; + } else if (event.kind == LinuxX11EventKind.expose) { + needs_present = 1; + } else if (event.kind == LinuxX11EventKind.resize) { + target_width = preview_clamp_dimension(event.width, 320, 4096); + target_height = preview_clamp_dimension(event.height, 240, 2160); + needs_rebuild = 1; + // Preserve event order: rebuild shared geometry before any + // later queued pointer event is resolved against it. + draining = 0; + } else if (event.kind == LinuxX11EventKind.pointer_move) { + let hit: HitResult = preview_event_hit(&frame, event); + let segment_index: i32 = preview_segment_index(hit.id); + let before: SegmentedControlModel = segmented_model; + let previous_hover: NodeKey = interaction.hovered; + let previous_pressed: NodeKey = interaction.pressed; + interaction.hovered = hit_root_key(); + if (hit.node_index >= 0 && + frame.hits.nodes.data[hit.node_index].enabled != 0 && + preview_general_interactive(hit.id) != 0) { + interaction.hovered = hit.id; + } + interaction.pressed = hit_root_key(); + if (preview_general_interactive(pointer_pressed) != 0 && + node_key_equal(pointer_pressed, + interaction.hovered) != 0) { + interaction.pressed = pointer_pressed; + } + if (segment_index >= 0 && hit.node_index >= 0 && + frame.hits.nodes.data[hit.node_index].enabled != 0) { + if (segmented_model.hovered_index != segment_index) { + _ = preview_apply_segment_event(&segmented_model, + segmented_control_event( + SegmentedControlEventKind.pointer_enter, + segment_index)); + } + } else if (segmented_model.hovered_index >= 0) { + _ = preview_apply_segment_event(&segmented_model, + segmented_control_event( + SegmentedControlEventKind.pointer_leave, + segmented_model.hovered_index)); + } + if (preview_segment_model_equal( + before, segmented_model) == 0) { + needs_rebuild = 1; + if (input_trace != 0) { + _zag_print("zagkit: segment hover index="); + _zag_print_i64( + segmented_model.hovered_index as i64); + _zag_println(""); + } + } + if (node_key_equal(previous_hover, + interaction.hovered) == 0 || + node_key_equal(previous_pressed, + interaction.pressed) == 0) { + needs_rebuild = 1; + } + } else if (event.kind == LinuxX11EventKind.pointer_down) { + if (input_trace != 0) { + _zag_print("zagkit: pointer down x="); + _zag_print_i64(event.x as i64); + _zag_print(" y="); _zag_print_i64(event.y as i64); + _zag_print(" button="); _zag_print_i64(event.button as i64); + _zag_println(""); + } + if (event.button == 1) { + let hit: HitResult = preview_event_hit(&frame, event); + if (input_trace != 0) { preview_trace_hit(&frame, hit); } + if (hit.found != 0 && hit.node_index >= 0) { + let enabled: i32 = + frame.hits.nodes.data[hit.node_index].enabled; + let navigation_index: i32 = + preview_navigation_index(hit.id); + let segment_index: i32 = + preview_segment_index(hit.id); + if (navigation_index >= 0 && enabled == 0 && + input_trace != 0) { + _zag_print("zagkit: navigation unavailable route="); + _zag_println(linux_preview_navigation_name( + navigation_index)); + } else if (segment_index >= 0 && enabled != 0) { + pointer_pressed = hit.id; + interaction.pressed = hit_root_key(); + interaction.focused = hit_root_key(); + interaction.focus_visible = 0; + let before: SegmentedControlModel = + segmented_model; + let transition: SegmentedControlTransition = + preview_apply_segment_event(&segmented_model, + segmented_control_event( + SegmentedControlEventKind.pointer_down, + segment_index)); + if (transition.rejected == 0 && + preview_segment_model_equal( + before, segmented_model) == 0) { + needs_rebuild = 1; + draining = 0; + } + } else if (enabled != 0) { + pointer_pressed = hit.id; + interaction.pressed = hit.id; + interaction.focused = hit.id; + interaction.focus_visible = 0; + if (segmented_model.has_focus != 0) { + _ = preview_apply_segment_event( + &segmented_model, + segmented_control_event( + SegmentedControlEventKind.focus_lost, + segmented_model.roving_index)); + } + needs_rebuild = 1; + draining = 0; + } + } + } + } else if (event.kind == LinuxX11EventKind.pointer_up) { + if (event.button == 1) { + let hit: HitResult = preview_event_hit(&frame, event); + let segment_index: i32 = preview_segment_index(hit.id); + if (segmented_model.pressed_index >= 0) { + let before: SegmentedControlModel = segmented_model; + let transition: SegmentedControlTransition = + segmented_control_transition(segmented_model); + let target_id: NodeKey = hit_root_key(); + if (segment_index == + segmented_model.pressed_index && + hit.node_index >= 0 && + frame.hits.nodes.data[hit.node_index].enabled != 0) { + target_id = hit.id; + transition = preview_apply_segment_event( + &segmented_model, + segmented_control_event( + SegmentedControlEventKind.pointer_up_inside, + segment_index)); + } else { + transition = preview_apply_segment_event( + &segmented_model, + segmented_control_event( + SegmentedControlEventKind.pointer_cancel, + segmented_control_no_index())); + } + if (preview_segment_model_equal( + before, segmented_model) == 0) { + needs_rebuild = 1; + } + preview_trace_selection( + transition, target_id, input_trace); + } else if (hit.found != 0 && hit.node_index >= 0 && + frame.hits.nodes.data[hit.node_index].enabled != 0 && + node_key_equal(pointer_pressed, hit.id) != 0) { + let navigation_index: i32 = + preview_navigation_index(hit.id); + if (navigation_index >= 0 && + navigation_index != selected_navigation) { + selected_navigation = navigation_index; + needs_rebuild = 1; + if (input_trace != 0) { + _zag_print("zagkit: navigation selection target-id="); + _zag_print_i64(hit.id.value); + _zag_print(" selected-navigation="); + _zag_print_i64(selected_navigation as i64); + _zag_println(""); + } + } else if (node_key_equal(hit.id, + linux_preview_composition_inspect_id()) != 0) { + token_inspector_open = + (token_inspector_open == 0) as i32; + needs_rebuild = 1; + if (input_trace != 0) { + _zag_print("zagkit: token inspector open="); + _zag_print_i64(token_inspector_open as i64); + _zag_println(""); + } + } + } + pointer_pressed = hit_root_key(); + interaction.pressed = hit_root_key(); + interaction.hovered = hit_root_key(); + if (hit.node_index >= 0 && + frame.hits.nodes.data[hit.node_index].enabled != 0 && + preview_general_interactive(hit.id) != 0) { + interaction.hovered = hit.id; + } + needs_rebuild = 1; + } + } else if (event.kind == LinuxX11EventKind.key_down) { + let before: SegmentedControlModel = segmented_model; + let transition: SegmentedControlTransition = + segmented_control_transition(segmented_model); + if (event.keysym == 65289) { + let current: NodeKey = interaction.focused; + if (segmented_model.has_focus != 0) { + current = linux_preview_composition_segment_id( + segmented_model.roving_index); + } + let next: NodeKey = preview_next_focus( + &frame, current, + ((event.modifiers & 1) != 0) as i32); + let next_segment: i32 = preview_segment_index(next); + interaction.focused = hit_root_key(); + interaction.focus_visible = 0; + if (segmented_model.has_focus != 0) { + _ = preview_apply_segment_event(&segmented_model, + segmented_control_event( + SegmentedControlEventKind.focus_lost, + segmented_model.roving_index)); + } + if (next_segment >= 0) { + transition = preview_apply_segment_event( + &segmented_model, + segmented_control_event( + SegmentedControlEventKind.focus_target, + next_segment)); + } else if (node_key_equal(next, + semantic_root_key()) == 0) { + interaction.focused = next; + interaction.focus_visible = 1; + } + needs_rebuild = 1; + } else if (event.keysym == 65361 && + segmented_model.has_focus != 0) { + transition = preview_apply_segment_event(&segmented_model, + segmented_control_event( + SegmentedControlEventKind.key_previous, + segmented_control_no_index())); + } else if (event.keysym == 65363 && + segmented_model.has_focus != 0) { + transition = preview_apply_segment_event(&segmented_model, + segmented_control_event( + SegmentedControlEventKind.key_next, + segmented_control_no_index())); + } else if (event.keysym == 65360 && + segmented_model.has_focus != 0) { + transition = preview_apply_segment_event(&segmented_model, + segmented_control_event( + SegmentedControlEventKind.key_home, + segmented_control_no_index())); + } else if (event.keysym == 65367 && + segmented_model.has_focus != 0) { + transition = preview_apply_segment_event(&segmented_model, + segmented_control_event( + SegmentedControlEventKind.key_end, + segmented_control_no_index())); + } else if ((event.keysym == 65293 || event.keysym == 32) && + segmented_model.has_focus != 0) { + transition = preview_apply_segment_event(&segmented_model, + segmented_control_event( + SegmentedControlEventKind.activate_focused, + segmented_control_no_index())); + } else if ((event.keysym == 65293 || event.keysym == 32) && + node_key_equal(interaction.focused, + hit_root_key()) == 0) { + let navigation_index: i32 = preview_navigation_index( + interaction.focused); + if (navigation_index >= 0 && + navigation_index != selected_navigation) { + selected_navigation = navigation_index; + needs_rebuild = 1; + if (input_trace != 0) { + _zag_print("zagkit: keyboard navigation selection target-id="); + _zag_print_i64(interaction.focused.value); + _zag_print(" selected-navigation="); + _zag_print_i64(selected_navigation as i64); + _zag_println(""); + } + } else if (node_key_equal(interaction.focused, + linux_preview_composition_inspect_id()) != 0) { + token_inspector_open = + (token_inspector_open == 0) as i32; + needs_rebuild = 1; + if (input_trace != 0) { + _zag_print("zagkit: keyboard token inspector open="); + _zag_print_i64(token_inspector_open as i64); + _zag_println(""); + } + } + } + if (preview_segment_model_equal( + before, segmented_model) == 0) { + needs_rebuild = 1; + preview_trace_selection(transition, + linux_preview_composition_segment_id( + transition.selected_index), input_trace); + } + } else if (event.kind == LinuxX11EventKind.focus_out) { + interaction.hovered = hit_root_key(); + interaction.pressed = hit_root_key(); + interaction.focused = hit_root_key(); + interaction.focus_visible = 0; + needs_rebuild = 1; + if (segmented_model.has_focus != 0) { + let before: SegmentedControlModel = segmented_model; + _ = preview_apply_segment_event(&segmented_model, + segmented_control_event( + SegmentedControlEventKind.focus_lost, + segmented_model.roving_index)); + if (preview_segment_model_equal( + before, segmented_model) == 0) { needs_rebuild = 1; } + } + } + if (draining != 0) { draining = linux_x11_pending(&host); } + } + if (host.open != 0 && needs_rebuild != 0) { + let next_frame: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_fonts_interaction_state( + target_width, target_height, + &light_face, &face, &bold_face, + selected_navigation, segmented_model, + token_inspector_open, interaction); + if (preview_present(&host, &next_frame, + target_width, target_height, "") == 0) { + linux_preview_composition_free(&next_frame); + runtime_error = 1; + host.open = 0; + } else { + linux_preview_composition_free(&frame); + frame = next_frame; + selected_navigation = frame.selected_navigation; + segmented_model = frame.segmented_model; + token_inspector_open = frame.token_inspector_open; + interaction = frame.interaction; + if (input_trace != 0) { preview_trace_targets(&frame); } + } + } else if (host.open != 0 && needs_present != 0 && + preview_present(&host, &frame, + target_width, target_height, "") == 0) { + runtime_error = 1; + host.open = 0; } } + linux_preview_composition_free(&frame); linux_x11_close(&host); + opentype_face_free(&bold_face); opentype_face_free(&face); + opentype_face_free(&light_face); + if (runtime_error != 0) { return 4; } return 0; } diff --git a/src/automation/talkback.zag b/src/automation/talkback.zag index 413303c..9e1fc90 100644 --- a/src/automation/talkback.zag +++ b/src/automation/talkback.zag @@ -23,6 +23,13 @@ enum TalkbackCommand { replay, } +enum TalkbackScrollAxis { + none, + horizontal, + vertical, + both, +} + enum TalkbackStatus { accepted, invalid_request, @@ -35,6 +42,18 @@ enum TalkbackStatus { unsupported_command, } +struct TalkbackPixelFallbackTarget { + status: TalkbackStatus, + source_id: NodeKey, + bounds_x: i64, + bounds_y: i64, + logical_bounds: Rect, + pixel_x: i64, + pixel_y: i64, + action_available: i32, + evidence_hash: i64, +} + struct TalkbackCapabilities { protocol_major: i64, protocol_minor: i64, @@ -62,6 +81,9 @@ struct TalkbackRequest { pixel_y: i64, scale_numerator: i64, scale_denominator: i64, + scroll_axis: TalkbackScrollAxis, + scroll_delta_x: i64, + scroll_delta_y: i64, } struct TalkbackResponse { @@ -103,6 +125,9 @@ struct TalkbackEvent { pixel_y: i64, scale_numerator: i64, scale_denominator: i64, + scroll_axis: TalkbackScrollAxis, + scroll_delta_x: i64, + scroll_delta_y: i64, } struct TalkbackSession { @@ -153,9 +178,23 @@ fn talkback_request(request_id: i64, command: TalkbackCommand, target_id: NodeKe .pixel_y = 0, .scale_numerator = 1, .scale_denominator = 1, + .scroll_axis = TalkbackScrollAxis.none, + .scroll_delta_x = 0, + .scroll_delta_y = 0, }; } +fn talkback_scroll_request(request_id: i64, target_id: NodeKey, + semantics_revision: i64, axis: TalkbackScrollAxis, + delta_x: i64, delta_y: i64) TalkbackRequest { + let request: TalkbackRequest = talkback_request(request_id, + TalkbackCommand.scroll, target_id, semantics_revision); + request.scroll_axis = axis; + request.scroll_delta_x = delta_x; + request.scroll_delta_y = delta_y; + return request; +} + fn talkback_pixel_request(request_id: i64, command: TalkbackCommand, pixel_x: i64, pixel_y: i64, scale_numerator: i64, scale_denominator: i64) TalkbackRequest { let request: TalkbackRequest = talkback_request(request_id, command, semantic_root_key(), 0 - 1); request.target_kind = TalkbackTargetKind.pixel; @@ -344,6 +383,9 @@ fn talkback_record(session: *TalkbackSession, tree_revision: i64, request: Talkb .pixel_y = request.pixel_y, .scale_numerator = request.scale_numerator, .scale_denominator = request.scale_denominator, + .scroll_axis = request.scroll_axis, + .scroll_delta_x = request.scroll_delta_x, + .scroll_delta_y = request.scroll_delta_y, }; push[TalkbackEvent](&session.*.events, recorded_event); return TalkbackResponse{ @@ -470,9 +512,71 @@ fn talkback_dispatch(session: *TalkbackSession, tree: SemanticsTree, viewport: R if (tree.nodes.data[node_index].hidden != 0) { response.state_flags = response.state_flags | 4; } + if (tree.nodes.data[node_index].focused != 0) { + response.state_flags = response.state_flags | 8; + } response.evidence_hash = semantic_node_evidence_hash(tree.nodes.data[node_index]); if (required_action != 0 || talkback_emits_action(request.command) != 0) { response.emitted_action = 1; } return response; } + +fn talkback_state_focused_bit() i64 { return 8; } +fn talkback_state_selected_bit() i64 { return 2; } +fn talkback_state_disabled_bit() i64 { return 1; } + +fn talkback_pixel_fallback_for_id(tree: SemanticsTree, + viewport: Rect, target_id: NodeKey, command: TalkbackCommand, + scale_numerator: i64, scale_denominator: i64) TalkbackPixelFallbackTarget { + let fallback: TalkbackPixelFallbackTarget = TalkbackPixelFallbackTarget{ + .status = TalkbackStatus.target_not_found, + .source_id = target_id, + .bounds_x = 0, + .bounds_y = 0, + .logical_bounds = rect(0, 0, 0, 0), + .pixel_x = 0, + .pixel_y = 0, + .action_available = 0, + .evidence_hash = 0, + }; + let nodes: ArrayList[SemanticsNode] = tree.nodes; + let node_index: i32 = semantics_find_index(tree, target_id); + if (node_index < 0 || node_index >= nodes.len) { return fallback; } + let node_bounds: Rect = nodes.data[node_index].bounds; + let node_evidence: i64 = semantic_node_evidence_hash(nodes.data[node_index]); + let center_x: i64 = node_bounds.x + node_bounds.width / 2; + let center_y: i64 = node_bounds.y + node_bounds.height / 2; + let pixel_x: i64 = (center_x * scale_numerator) / + (scale_denominator * unit_scale()); + let pixel_y: i64 = (center_y * scale_numerator) / + (scale_denominator * unit_scale()); + let viewport_right: i64 = viewport.x + viewport.width; + let viewport_bottom: i64 = viewport.y + viewport.height; + if (center_x < viewport.x || center_x > viewport_right || + center_y < viewport.y || center_y > viewport_bottom) { + let out_of_bounds: TalkbackPixelFallbackTarget = TalkbackPixelFallbackTarget{ + .status = TalkbackStatus.pixel_out_of_bounds, + .source_id = target_id, + .bounds_x = node_bounds.x, + .bounds_y = node_bounds.y, + .logical_bounds = node_bounds, + .pixel_x = pixel_x, + .pixel_y = pixel_y, + .action_available = 0, + .evidence_hash = node_evidence, + }; + return out_of_bounds; + } + return TalkbackPixelFallbackTarget{ + .status = TalkbackStatus.accepted, + .source_id = target_id, + .bounds_x = node_bounds.x, + .bounds_y = node_bounds.y, + .logical_bounds = node_bounds, + .pixel_x = pixel_x, + .pixel_y = pixel_y, + .action_available = 1, + .evidence_hash = node_evidence, + }; +} diff --git a/src/components/button.zag b/src/components/button.zag index 1ac2bd4..c4b471f 100644 --- a/src/components/button.zag +++ b/src/components/button.zag @@ -31,6 +31,8 @@ enum ButtonEvent { pointer_up_inside, pointer_cancel, focus_gained, + focus_gained_pointer, + focus_gained_keyboard, focus_lost, select, deselect, @@ -48,6 +50,7 @@ enum ButtonBuildError { none, invalid_spec, display_error, hit_error, semantics_ struct ButtonInteraction { hovered: i32, focused: i32, + focus_visible: i32, pressed: i32, selected: i32, enabled: i32, @@ -104,6 +107,7 @@ struct ButtonArtifact { style: ButtonResolvedStyle, effectively_enabled: i32, content_visible: i32, + focus_ring_visible: i32, display_op_start: i32, display_op_end: i32, hit_node_index: i32, @@ -134,8 +138,8 @@ struct ButtonBuildCheckpoint { fn button_interaction() ButtonInteraction { return ButtonInteraction{ - .hovered = 0, .focused = 0, .pressed = 0, .selected = 0, - .enabled = 1, .loading = 0, .error = 0, + .hovered = 0, .focused = 0, .focus_visible = 0, .pressed = 0, + .selected = 0, .enabled = 1, .loading = 0, .error = 0, }; } @@ -145,7 +149,7 @@ fn button_description_byte_limit() i32 { return 16384; } fn button_canonical_state(state: ButtonCanonicalState) ButtonInteraction { let value: ButtonInteraction = button_interaction(); if (state == ButtonCanonicalState.hover) { value.hovered = 1; } - if (state == ButtonCanonicalState.focus) { value.focused = 1; } + if (state == ButtonCanonicalState.focus) { value.focused = 1; value.focus_visible = 1; } if (state == ButtonCanonicalState.pressed) { value.pressed = 1; } if (state == ButtonCanonicalState.selected) { value.selected = 1; } if (state == ButtonCanonicalState.disabled) { value.enabled = 0; } @@ -192,13 +196,21 @@ fn button_reduce(current: ButtonInteraction, event: ButtonEvent) ButtonTransitio } .pointer_cancel => { next.pressed = 0; } .focus_gained => { if (available != 0) { next.focused = 1; } } - .focus_lost => { next.focused = 0; next.pressed = 0; } + .focus_gained_pointer => { + if (available != 0) { next.focused = 1; next.focus_visible = 0; } + } + .focus_gained_keyboard => { + if (available != 0) { next.focused = 1; next.focus_visible = 1; } + } + .focus_lost => { + next.focused = 0; next.focus_visible = 0; next.pressed = 0; + } .select => { next.selected = 1; } .deselect => { next.selected = 0; } .enable => { next.enabled = 1; } .disable => { next.enabled = 0; next.hovered = 0; next.focused = 0; - next.pressed = 0; + next.focus_visible = 0; next.pressed = 0; } .begin_loading => { next.loading = 1; next.pressed = 0; } .finish_loading => { next.loading = 0; } @@ -408,7 +420,8 @@ fn button_artifact_hash(artifact: ButtonArtifact) i64 { hash = button_hash_mix(hash, artifact.style.radius); hash = button_hash_mix(hash, artifact.style.elevation as i64); hash = button_hash_mix(hash, artifact.effectively_enabled as i64); - return button_hash_mix(hash, artifact.content_visible as i64); + hash = button_hash_mix(hash, artifact.content_visible as i64); + return button_hash_mix(hash, artifact.focus_ring_visible as i64); } fn button_empty_artifact(spec: ButtonSpec) ButtonArtifact { @@ -417,6 +430,7 @@ fn button_empty_artifact(spec: ButtonSpec) ButtonArtifact { .content_bounds = rect(0, 0, 0, 0), .interaction = spec.interaction, .style = button_style(spec), .effectively_enabled = 0, .content_visible = 0, + .focus_ring_visible = 0, .display_op_start = 0 - 1, .display_op_end = 0 - 1, .hit_node_index = 0 - 1, .semantics_node_index = 0 - 1, .evidence_hash = 0, @@ -486,7 +500,8 @@ fn button_emit(spec: ButtonSpec, display: *DisplayList, hits: *HitTree, let op_start: i32 = display.*.ops.len; let display_error: DisplayError = DisplayError.none; - if (spec.interaction.focused != 0 && enabled != 0) { + if (spec.interaction.focused != 0 && enabled != 0 && + spec.interaction.focus_visible != 0) { let focus_extent: i64 = 3 * unit_scale(); display_error = button_push_round(display, spec.id, rect(spec.bounds.x - focus_extent, spec.bounds.y - focus_extent, @@ -495,17 +510,17 @@ fn button_emit(spec: ButtonSpec, display: *DisplayList, hits: *HitTree, style.radius + focus_extent, semantic_color_with_alpha(style.focus_token, 39000)); } - if (display_error == DisplayError.none && elevation.shadow_alpha > 0 && + if (display_error == DisplayError.none && elevation.ambient_shadow_alpha > 0 && spec.interaction.pressed == 0 && enabled != 0) { - let spread: i64 = elevation.shadow_spread; + let spread: i64 = elevation.ambient_shadow_blur; display_error = button_push_round(display, spec.id, rect(spec.bounds.x - spread, - spec.bounds.y + elevation.shadow_offset_y - spread, + spec.bounds.y + elevation.ambient_shadow_y - spread, spec.bounds.width + spread * 2, spec.bounds.height + spread * 2), style.radius + spread, semantic_color_with_alpha(SemanticColorToken.shadow, - elevation.shadow_alpha)); + elevation.ambient_shadow_alpha)); } if (display_error == DisplayError.none) { display_error = button_push_round(display, spec.id, spec.bounds, @@ -559,6 +574,7 @@ fn button_emit(spec: ButtonSpec, display: *DisplayList, hits: *HitTree, semantic.focus_order = spec.focus_order; semantic.disabled = (enabled == 0) as i32; semantic.selected = spec.interaction.selected; + semantic.focused = (spec.interaction.focused != 0 && enabled != 0) as i32; semantic.has_bounds = 1; semantic.bounds = spec.bounds; if (enabled != 0) { @@ -579,6 +595,8 @@ fn button_emit(spec: ButtonSpec, display: *DisplayList, hits: *HitTree, .interaction = spec.interaction, .style = style, .effectively_enabled = enabled, .content_visible = (spec.interaction.loading == 0) as i32, + .focus_ring_visible = (spec.interaction.focus_visible != 0 && + enabled != 0) as i32, .display_op_start = op_start, .display_op_end = display.*.ops.len, .hit_node_index = hits.*.nodes.len - 1, .semantics_node_index = semantics.*.nodes.len - 1, diff --git a/src/components/navigation_item.zag b/src/components/navigation_item.zag index a2dbd0a..f97df0d 100644 --- a/src/components/navigation_item.zag +++ b/src/components/navigation_item.zag @@ -402,17 +402,17 @@ fn navigation_item_emit(spec: NavigationItemSpec, display: *DisplayList, style.radius + extent, semantic_color_with_alpha(style.focus_token, 42000)); } - if (display_error == DisplayError.none && elevation.shadow_alpha > 0 && + if (display_error == DisplayError.none && elevation.ambient_shadow_alpha > 0 && spec.interaction.pressed == 0 && enabled != 0) { - let spread: i64 = elevation.shadow_spread; + let spread: i64 = elevation.ambient_shadow_blur; display_error = navigation_item_push_round(display, spec.id, rect(spec.bounds.x - spread, - spec.bounds.y + elevation.shadow_offset_y - spread, + spec.bounds.y + elevation.ambient_shadow_y - spread, spec.bounds.width + spread * 2, spec.bounds.height + spread * 2), style.radius + spread, semantic_color_with_alpha(SemanticColorToken.shadow, - elevation.shadow_alpha)); + elevation.ambient_shadow_alpha)); } if (display_error == DisplayError.none && style.border_alpha > 0) { display_error = navigation_item_push_round(display, spec.id, @@ -420,12 +420,8 @@ fn navigation_item_emit(spec: NavigationItemSpec, display: *DisplayList, semantic_color_with_alpha(style.border_token, style.border_alpha)); } if (display_error == DisplayError.none && style.fill_alpha > 0) { - let edge: i64 = unit_scale(); display_error = navigation_item_push_round(display, spec.id, - rect(spec.bounds.x + edge, spec.bounds.y + edge, - spec.bounds.width - edge * 2, - spec.bounds.height - edge * 2), - style.radius - edge, + spec.bounds, style.radius, semantic_color_with_alpha(style.fill_token, style.fill_alpha)); } if (display_error == DisplayError.none && spec.interaction.selected != 0) { diff --git a/src/components/performance_chart.zag b/src/components/performance_chart.zag index d696f5c..b815614 100644 --- a/src/components/performance_chart.zag +++ b/src/components/performance_chart.zag @@ -207,6 +207,29 @@ fn performance_chart_series(id: NodeKey, name: []u8, }; } +// Series roles map to the three chart_series color tokens so that callers +// express intent rather than raw color indices. +// reference → chart_series_primary (blue) — the baseline metric +// comparison → chart_series_secondary (purple) — compared against reference +// diagnostic → chart_series_tertiary (orange) — supplementary diagnostic +// In the Linux preview the mapping is: +// State propagation → reference, Layout and paint → comparison, +// Motion scheduler → diagnostic, matching segment indices 0/1/2. +enum PerformanceChartSeriesToken { reference, comparison, diagnostic } + +fn performance_chart_series_role(id: NodeKey, name: []u8, + points: ArrayList[PerformanceChartPoint], + role: PerformanceChartSeriesToken) PerformanceChartSeries { + let color: SemanticColorToken = SemanticColorToken.chart_series_primary; + if (role == PerformanceChartSeriesToken.comparison) { + color = SemanticColorToken.chart_series_secondary; + } + if (role == PerformanceChartSeriesToken.diagnostic) { + color = SemanticColorToken.chart_series_tertiary; + } + return performance_chart_series(id, name, points, color); +} + fn performance_chart_spec(id: NodeKey, bounds: Rect, series: ArrayList[PerformanceChartSeries], x_tick_labels: ArrayList[[]u8], @@ -534,15 +557,15 @@ fn performance_chart_background(artifact: *PerformanceChartArtifact, let scale: i64 = unit_scale(); let elevation: ResolvedElevationToken = semantic_elevation(SemanticElevationToken.raised); - let spread: i64 = elevation.shadow_spread; + let spread: i64 = elevation.ambient_shadow_blur; if (performance_chart_push_round(artifact, rect(spec.bounds.x - spread, - spec.bounds.y + elevation.shadow_offset_y - spread, + spec.bounds.y + elevation.ambient_shadow_y - spread, spec.bounds.width + spread * 2, spec.bounds.height + spread * 2), semantic_radius(SemanticRadiusToken.panel) + spread, semantic_color_with_alpha(SemanticColorToken.shadow, - elevation.shadow_alpha)) == 0) { return 0; } + elevation.ambient_shadow_alpha)) == 0) { return 0; } if (performance_chart_push_round(artifact, spec.bounds, semantic_radius(SemanticRadiusToken.panel), semantic_color_paint(SemanticColorToken.border_emphasis)) == 0) { return 0; } diff --git a/src/components/scroll_viewport.zag b/src/components/scroll_viewport.zag new file mode 100644 index 0000000..850b995 --- /dev/null +++ b/src/components/scroll_viewport.zag @@ -0,0 +1,1784 @@ +@import("../core/geometry.zag") +@import("../core/view_contract.zag") +@import("../design/tokens.zag") +@import("../input/hit_test.zag") +@import("../layout/adaptive.zag") +@import("../layout/scroll.zag") +@import("../render/display_list.zag") +@import("../semantics/semantics.zag") + +// ScrollViewport is the retained presentation and interaction contract around +// layout/scroll.zag. ScrollState remains the only offset authority. The +// geometry derived here is reused for display clipping and translation, hit +// clipping, semantic bounds, scrollbar chrome, and ID-based automation. + +enum ScrollViewportAxis { vertical, horizontal, both } + +enum ScrollViewportInputKind { wheel, key, semantic, focus_reveal } + +enum ScrollViewportCommand { + arrow_up, + arrow_down, + arrow_left, + arrow_right, + page_up, + page_down, + page_left, + page_right, + home, + end, +} + +enum ScrollViewportBuildError { + none, + invalid_spec, + invalid_state, + invalid_content, + invalid_destination, + capacity_error, + resource_collision, + display_error, + hit_error, + semantics_error, +} + +enum ScrollViewportCapacityError { + none, + display_operations, + display_resources, + hit_nodes, + semantic_nodes, +} + +struct ScrollViewportBuildLimits { + display_operations: i32, + hit_nodes: i32, + semantic_nodes: i32, +} + +struct ScrollViewportSpec { + id: NodeKey, + semantic_parent: NodeKey, + hit_parent: NodeKey, + bounds: Rect, + name: []u8, + description: []u8, + value: []u8, + axis: ScrollViewportAxis, + direction: LayoutDirection, + density: FlexDensity, + enabled: i32, + focused: i32, + focus_visible: i32, + focus_order: i64, + content_focus_order_offset: i64, + z_order: i64, + limits: ScrollViewportBuildLimits, +} + +struct ScrollViewportGeometry { + viewport_bounds: Rect, + clip_bounds: Rect, + visible_content_bounds: Rect, + content_transform: Transform, + horizontal_track_bounds: Rect, + horizontal_thumb_bounds: Rect, + vertical_track_bounds: Rect, + vertical_thumb_bounds: Rect, + horizontal_track_visible: i32, + vertical_track_visible: i32, + horizontal_max: i64, + vertical_max: i64, + horizontal_value: i64, + vertical_value: i64, + evidence_hash: i64, + valid: i32, +} + +struct ScrollViewportArtifact { + id: NodeKey, + geometry: ScrollViewportGeometry, + state_hash: i64, + scrollable: i32, + enabled: i32, + focused: i32, + focus_ring_visible: i32, + track_token: SemanticColorToken, + thumb_token: SemanticColorToken, + focus_token: SemanticColorToken, + display_op_start: i32, + display_op_end: i32, + display_operations_added: i32, + display_resources_added: i32, + hit_node_start: i32, + hit_node_end: i32, + semantic_node_start: i32, + semantic_node_end: i32, + evidence_hash: i64, +} + +struct ScrollViewportBuildResult { + artifact: ScrollViewportArtifact, + error: ScrollViewportBuildError, + capacity_error: ScrollViewportCapacityError, + display_error: DisplayError, + resource_error: RenderResourceError, + hit_error: HitTreeError, + semantics_error: SemanticsError, +} + +struct ScrollViewportTransition { + mutation: ScrollMutation, + input_kind: ScrollViewportInputKind, + command: ScrollViewportCommand, + requested_x: i64, + requested_y: i64, + accepted: i32, + rejected: i32, + rejected_disabled: i32, + rejected_no_overflow: i32, + evidence_hash: i64, +} + +struct ScrollViewportTargetReveal { + id: NodeKey, + content_bounds: Rect, + found: i32, + focusable: i32, + transition: ScrollViewportTransition, + evidence_hash: i64, +} + +struct ScrollViewportHostCheckpoint { + semantics: SemanticsCheckpoint, + hit_length: i32, + hit_revision: i64, + hit_last_error: HitTreeError, + hit_error_node: NodeKey, +} + +struct ScrollViewportDisplayStage { + display: DisplayList, + valid: i32, +} + +fn scroll_viewport_name_byte_limit() i32 { return 4096; } +fn scroll_viewport_description_byte_limit() i32 { return 16384; } +fn scroll_viewport_focus_order_limit() i64 { return 1000000000; } + +fn scroll_viewport_horizontal_range_id(parent: NodeKey) NodeKey { + return semantic_generated_key(parent, SemanticRole.slider, 7001); +} + +fn scroll_viewport_vertical_range_id(parent: NodeKey) NodeKey { + return semantic_generated_key(parent, SemanticRole.slider, 7002); +} + +fn scroll_viewport_build_limits() ScrollViewportBuildLimits { + return ScrollViewportBuildLimits{ + .display_operations = 16384, + .hit_nodes = 4096, + .semantic_nodes = 4096, + }; +} + +fn scroll_viewport_spec(id: NodeKey, semantic_parent: NodeKey, + hit_parent: NodeKey, bounds: Rect, + name: @retained_by_return []u8) ScrollViewportSpec { + return ScrollViewportSpec{ + .id = id, + .semantic_parent = semantic_parent, + .hit_parent = hit_parent, + .bounds = bounds, + .name = name, + .description = "Clipped scrollable content", + .value = "Scroll position", + .axis = ScrollViewportAxis.vertical, + .direction = LayoutDirection.ltr, + .density = FlexDensity.standard, + .enabled = 1, + .focused = 0, + .focus_visible = 0, + .focus_order = 0, + .content_focus_order_offset = 0, + .z_order = 0, + .limits = scroll_viewport_build_limits(), + }; +} + +fn scroll_viewport_axis_valid(axis: ScrollViewportAxis) i32 { + let value: i64 = axis as i64; + return (value >= ScrollViewportAxis.vertical as i64 && + value <= ScrollViewportAxis.both as i64) as i32; +} + +fn scroll_viewport_direction_valid(direction: LayoutDirection) i32 { + return (direction == LayoutDirection.ltr || + direction == LayoutDirection.rtl) as i32; +} + +fn scroll_viewport_density_valid(density: FlexDensity) i32 { + let value: i64 = density as i64; + return (value >= FlexDensity.compact as i64 && + value <= FlexDensity.touch as i64) as i32; +} + +fn scroll_viewport_axis_x(axis: ScrollViewportAxis) i32 { + return (axis == ScrollViewportAxis.horizontal || + axis == ScrollViewportAxis.both) as i32; +} + +fn scroll_viewport_axis_y(axis: ScrollViewportAxis) i32 { + return (axis == ScrollViewportAxis.vertical || + axis == ScrollViewportAxis.both) as i32; +} + +fn scroll_viewport_spec_valid(spec: ScrollViewportSpec) i32 { + if (node_key_equal(spec.id, hit_root_key()) != 0 || + node_key_equal(spec.id, semantic_none_key()) != 0 || + node_key_equal(spec.semantic_parent, semantic_none_key()) != 0 || + node_key_equal(spec.hit_parent, semantic_none_key()) != 0 || + hit_geometry_valid(spec.bounds) == 0 || + spec.name.len <= 0 || spec.name.len > scroll_viewport_name_byte_limit() || + spec.description.len > scroll_viewport_description_byte_limit() || + spec.value.len > scroll_viewport_description_byte_limit() || + semantic_text_input_valid(spec.name) == 0 || + semantic_text_input_valid(spec.description) == 0 || + semantic_text_input_valid(spec.value) == 0 || + scroll_viewport_axis_valid(spec.axis) == 0 || + scroll_viewport_direction_valid(spec.direction) == 0 || + scroll_viewport_density_valid(spec.density) == 0 || + (spec.enabled != 0 && spec.enabled != 1) || + (spec.focused != 0 && spec.focused != 1) || + (spec.focus_visible != 0 && spec.focus_visible != 1) || + spec.focus_order < 0 || + spec.focus_order > scroll_viewport_focus_order_limit() || + spec.content_focus_order_offset < 0 || + spec.content_focus_order_offset > scroll_viewport_focus_order_limit() || + spec.z_order < 0 || spec.z_order >= scroll_viewport_focus_order_limit() || + spec.limits.display_operations < 0 || spec.limits.hit_nodes < 0 || + spec.limits.semantic_nodes < 0) { return 0; } + if ((spec.focused != 0 && (spec.enabled == 0 || spec.focus_order <= 0)) || + (spec.focus_visible != 0 && spec.focused == 0)) { return 0; } + return 1; +} + +fn scroll_viewport_max(a: i64, b: i64) i64 { + if (a > b) { return a; } + return b; +} + +fn scroll_viewport_min(a: i64, b: i64) i64 { + if (a < b) { return a; } + return b; +} + +fn scroll_viewport_intersection(a: Rect, b: Rect) Rect { + let x: i64 = scroll_viewport_max(a.x, b.x); + let y: i64 = scroll_viewport_max(a.y, b.y); + let right: i64 = scroll_viewport_min(a.x + a.width, b.x + b.width); + let bottom: i64 = scroll_viewport_min(a.y + a.height, b.y + b.height); + if (right <= x || bottom <= y) { return rect(x, y, 0, 0); } + return rect(x, y, right - x, bottom - y); +} + +fn scroll_viewport_rect_nonempty(value: Rect) i32 { + return (value.width > 0 && value.height > 0) as i32; +} + +fn scroll_viewport_mix(hash: i64, value: i64) i64 { + let modulus: i64 = 2147483647; + let component: i64 = value % modulus; + if (component < 0) { component = component + modulus; } + return (hash * 131 + component) % modulus; +} + +fn scroll_viewport_geometry_hash(value: ScrollViewportGeometry) i64 { + let hash: i64 = scroll_viewport_mix(17, value.viewport_bounds.x); + hash = scroll_viewport_mix(hash, value.viewport_bounds.y); + hash = scroll_viewport_mix(hash, value.viewport_bounds.width); + hash = scroll_viewport_mix(hash, value.viewport_bounds.height); + hash = scroll_viewport_mix(hash, value.visible_content_bounds.x); + hash = scroll_viewport_mix(hash, value.visible_content_bounds.y); + hash = scroll_viewport_mix(hash, value.visible_content_bounds.width); + hash = scroll_viewport_mix(hash, value.visible_content_bounds.height); + hash = scroll_viewport_mix(hash, value.content_transform.translate_x); + hash = scroll_viewport_mix(hash, value.content_transform.translate_y); + hash = scroll_viewport_mix(hash, value.horizontal_track_bounds.x); + hash = scroll_viewport_mix(hash, value.horizontal_track_bounds.y); + hash = scroll_viewport_mix(hash, value.horizontal_track_bounds.width); + hash = scroll_viewport_mix(hash, value.horizontal_thumb_bounds.x); + hash = scroll_viewport_mix(hash, value.horizontal_thumb_bounds.width); + hash = scroll_viewport_mix(hash, value.vertical_track_bounds.x); + hash = scroll_viewport_mix(hash, value.vertical_track_bounds.y); + hash = scroll_viewport_mix(hash, value.vertical_track_bounds.height); + hash = scroll_viewport_mix(hash, value.vertical_thumb_bounds.y); + hash = scroll_viewport_mix(hash, value.vertical_thumb_bounds.height); + hash = scroll_viewport_mix(hash, value.horizontal_track_visible as i64); + hash = scroll_viewport_mix(hash, value.vertical_track_visible as i64); + hash = scroll_viewport_mix(hash, value.horizontal_max); + hash = scroll_viewport_mix(hash, value.vertical_max); + hash = scroll_viewport_mix(hash, value.horizontal_value); + return scroll_viewport_mix(hash, value.vertical_value); +} + +fn scroll_viewport_thumb_extent(track_extent: i64, viewport_extent: i64, + content_extent: i64, minimum: i64) i64 { + if (track_extent <= 0 || viewport_extent <= 0 || content_extent <= 0) { + return 0; + } + let extent: i64 = (track_extent * viewport_extent) / content_extent; + if (extent < minimum) { extent = minimum; } + if (extent > track_extent) { extent = track_extent; } + return extent; +} + +fn scroll_viewport_geometry(spec: ScrollViewportSpec, + state: ScrollState) ScrollViewportGeometry { + let result: ScrollViewportGeometry = ScrollViewportGeometry{ + .viewport_bounds = spec.bounds, + .clip_bounds = spec.bounds, + .visible_content_bounds = rect(0, 0, 0, 0), + .content_transform = transform_identity(), + .horizontal_track_bounds = rect(0, 0, 0, 0), + .horizontal_thumb_bounds = rect(0, 0, 0, 0), + .vertical_track_bounds = rect(0, 0, 0, 0), + .vertical_thumb_bounds = rect(0, 0, 0, 0), + .horizontal_track_visible = 0, + .vertical_track_visible = 0, + .horizontal_max = 0, + .vertical_max = 0, + .horizontal_value = state.offset_x, + .vertical_value = state.offset_y, + .evidence_hash = 0, + .valid = 0, + }; + if (scroll_viewport_spec_valid(spec) == 0 || + scroll_state_valid(state) != ScrollError.none || + state.viewport.width != spec.bounds.width || + state.viewport.height != spec.bounds.height) { return result; } + + result.visible_content_bounds = scroll_visible_rect(state, spec.direction); + result.content_transform.translate_x = spec.bounds.x - + result.visible_content_bounds.x; + result.content_transform.translate_y = spec.bounds.y - + result.visible_content_bounds.y; + result.horizontal_max = scroll_max_x(state); + result.vertical_max = scroll_max_y(state); + result.horizontal_track_visible = (scroll_viewport_axis_x(spec.axis) != 0 && + result.horizontal_max > 0) as i32; + result.vertical_track_visible = (scroll_viewport_axis_y(spec.axis) != 0 && + result.vertical_max > 0) as i32; + + let margin: i64 = flex_spacing(FlexSpacingToken.tiny, spec.density); + let thickness: i64 = flex_spacing(FlexSpacingToken.tiny, spec.density); + let minimum_thumb: i64 = flex_spacing(FlexSpacingToken.xlarge, spec.density); + if (result.horizontal_track_visible != 0) { + let leading_reserve: i64 = 0; + let trailing_reserve: i64 = 0; + if (result.vertical_track_visible != 0) { + if (spec.direction == LayoutDirection.rtl) { + leading_reserve = thickness + margin; + } else { + trailing_reserve = thickness + margin; + } + } + let track_width: i64 = spec.bounds.width - margin * 2 - + leading_reserve - trailing_reserve; + if (track_width <= 0) { return result; } + result.horizontal_track_bounds = rect( + spec.bounds.x + margin + leading_reserve, + spec.bounds.y + spec.bounds.height - margin - thickness, + track_width, thickness); + let thumb_width: i64 = scroll_viewport_thumb_extent(track_width, + state.viewport.width, state.content.width, minimum_thumb); + let travel: i64 = track_width - thumb_width; + let physical_value: i64 = state.offset_x; + if (spec.direction == LayoutDirection.rtl) { + physical_value = result.horizontal_max - state.offset_x; + } + let thumb_offset: i64 = 0; + if (result.horizontal_max > 0) { + thumb_offset = (travel * physical_value) / result.horizontal_max; + } + result.horizontal_thumb_bounds = rect( + result.horizontal_track_bounds.x + thumb_offset, + result.horizontal_track_bounds.y, thumb_width, thickness); + } + if (result.vertical_track_visible != 0) { + let trailing_reserve: i64 = 0; + if (result.horizontal_track_visible != 0) { + trailing_reserve = thickness + margin; + } + let track_height: i64 = spec.bounds.height - margin * 2 - + trailing_reserve; + if (track_height <= 0) { return result; } + let track_x: i64 = spec.bounds.x + spec.bounds.width - + margin - thickness; + if (spec.direction == LayoutDirection.rtl) { + track_x = spec.bounds.x + margin; + } + result.vertical_track_bounds = rect(track_x, + spec.bounds.y + margin, thickness, track_height); + let thumb_height: i64 = scroll_viewport_thumb_extent(track_height, + state.viewport.height, state.content.height, minimum_thumb); + let travel: i64 = track_height - thumb_height; + let thumb_offset: i64 = 0; + if (result.vertical_max > 0) { + thumb_offset = (travel * state.offset_y) / result.vertical_max; + } + result.vertical_thumb_bounds = rect(track_x, + result.vertical_track_bounds.y + thumb_offset, + thickness, thumb_height); + } + result.valid = 1; + result.evidence_hash = scroll_viewport_geometry_hash(result); + return result; +} + +fn scroll_viewport_axis_has_overflow(state: ScrollState, + axis: ScrollViewportAxis) i32 { + return ((scroll_viewport_axis_x(axis) != 0 && scroll_max_x(state) > 0) || + (scroll_viewport_axis_y(axis) != 0 && scroll_max_y(state) > 0)) as i32; +} + +fn scroll_viewport_transition_hash(value: ScrollViewportTransition) i64 { + let hash: i64 = scroll_viewport_mix(17, value.mutation.mutation_hash); + hash = scroll_viewport_mix(hash, value.input_kind as i64); + hash = scroll_viewport_mix(hash, value.command as i64); + hash = scroll_viewport_mix(hash, value.requested_x); + hash = scroll_viewport_mix(hash, value.requested_y); + hash = scroll_viewport_mix(hash, value.accepted as i64); + hash = scroll_viewport_mix(hash, value.rejected as i64); + hash = scroll_viewport_mix(hash, value.rejected_disabled as i64); + return scroll_viewport_mix(hash, value.rejected_no_overflow as i64); +} + +fn scroll_viewport_transition(state: ScrollState, + input_kind: ScrollViewportInputKind, + command: ScrollViewportCommand) ScrollViewportTransition { + let value: ScrollViewportTransition = ScrollViewportTransition{ + .mutation = scroll_error_mutation(state, ScrollError.none), + .input_kind = input_kind, + .command = command, + .requested_x = 0, + .requested_y = 0, + .accepted = 0, + .rejected = 0, + .rejected_disabled = 0, + .rejected_no_overflow = 0, + .evidence_hash = 0, + }; + value.evidence_hash = scroll_viewport_transition_hash(value); + return value; +} + +fn scroll_viewport_reject(state: ScrollState, + input_kind: ScrollViewportInputKind, + command: ScrollViewportCommand, + disabled: i32, no_overflow: i32, + error: ScrollError) ScrollViewportTransition { + let result: ScrollViewportTransition = scroll_viewport_transition( + state, input_kind, command); + result.mutation = scroll_error_mutation(state, error); + result.rejected = 1; + result.rejected_disabled = disabled; + result.rejected_no_overflow = no_overflow; + result.evidence_hash = scroll_viewport_transition_hash(result); + return result; +} + +fn scroll_viewport_apply_delta(state: *ScrollState, + axis: ScrollViewportAxis, direction: LayoutDirection, enabled: i32, + input_kind: ScrollViewportInputKind, + command: ScrollViewportCommand, + raw_x: i64, raw_y: i64) ScrollViewportTransition { + if (scroll_viewport_axis_valid(axis) == 0 || + scroll_viewport_direction_valid(direction) == 0 || + (enabled != 0 && enabled != 1) || + scroll_state_valid(state.*) != ScrollError.none) { + return scroll_viewport_reject(state.*, input_kind, command, + 0, 0, scroll_state_valid(state.*)); + } + if (enabled == 0) { + return scroll_viewport_reject(state.*, input_kind, command, + 1, 0, ScrollError.none); + } + if (scroll_viewport_axis_has_overflow(state.*, axis) == 0) { + return scroll_viewport_reject(state.*, input_kind, command, + 0, 1, ScrollError.none); + } + let delta_x: i64 = raw_x; + let delta_y: i64 = raw_y; + if (scroll_viewport_axis_x(axis) == 0) { delta_x = 0; } + if (scroll_viewport_axis_y(axis) == 0) { delta_y = 0; } + if (direction == LayoutDirection.rtl) { delta_x = 0 - delta_x; } + let result: ScrollViewportTransition = scroll_viewport_transition( + state.*, input_kind, command); + result.requested_x = delta_x; + result.requested_y = delta_y; + result.mutation = scroll_by(state, delta_x, delta_y); + result.accepted = (result.mutation.error == ScrollError.none) as i32; + result.rejected = (result.accepted == 0) as i32; + result.evidence_hash = scroll_viewport_transition_hash(result); + return result; +} + +fn scroll_viewport_wheel(state: *ScrollState, + axis: ScrollViewportAxis, direction: LayoutDirection, enabled: i32, + delta_x: i64, delta_y: i64) ScrollViewportTransition { + return scroll_viewport_apply_delta(state, axis, direction, enabled, + ScrollViewportInputKind.wheel, + scroll_viewport_command_for_delta(axis, delta_x, delta_y), + delta_x, delta_y); +} + +// Derive the canonical command label from the dominant signed delta so the +// transition identity honestly reflects the user's intent. The actual offset +// is applied from the raw deltas; this only affects the command metadata and +// evidence hash. +fn scroll_viewport_command_for_delta(axis: ScrollViewportAxis, + delta_x: i64, delta_y: i64) ScrollViewportCommand { + if (scroll_viewport_axis_y(axis) != 0) { + if (delta_y > 0) { return ScrollViewportCommand.page_down; } + if (delta_y < 0) { return ScrollViewportCommand.page_up; } + } + if (scroll_viewport_axis_x(axis) != 0) { + if (delta_x > 0) { return ScrollViewportCommand.page_right; } + if (delta_x < 0) { return ScrollViewportCommand.page_left; } + } + return ScrollViewportCommand.page_down; +} + +fn scroll_viewport_line_step(density: FlexDensity) i64 { + return flex_spacing(FlexSpacingToken.xlarge, density) * 2; +} + +fn scroll_viewport_page_step(viewport: i64, density: FlexDensity) i64 { + let inset: i64 = flex_spacing(FlexSpacingToken.xlarge, density); + let value: i64 = viewport - inset * 2; + let minimum: i64 = scroll_viewport_line_step(density); + if (value < minimum) { return minimum; } + return value; +} + +fn scroll_viewport_apply_command(state: *ScrollState, + axis: ScrollViewportAxis, direction: LayoutDirection, enabled: i32, + density: FlexDensity, command: ScrollViewportCommand, + input_kind: ScrollViewportInputKind) ScrollViewportTransition { + if (scroll_viewport_density_valid(density) == 0) { + return scroll_viewport_reject(state.*, input_kind, command, + 0, 0, ScrollError.invalid_target); + } + let line: i64 = scroll_viewport_line_step(density); + let page_x: i64 = scroll_viewport_page_step(state.*.viewport.width, density); + let page_y: i64 = scroll_viewport_page_step(state.*.viewport.height, density); + if (command == ScrollViewportCommand.arrow_up) { + return scroll_viewport_apply_delta(state, axis, direction, enabled, + input_kind, command, 0, 0 - line); + } + if (command == ScrollViewportCommand.arrow_down) { + return scroll_viewport_apply_delta(state, axis, direction, enabled, + input_kind, command, 0, line); + } + if (command == ScrollViewportCommand.arrow_left) { + return scroll_viewport_apply_delta(state, axis, direction, enabled, + input_kind, command, 0 - line, 0); + } + if (command == ScrollViewportCommand.arrow_right) { + return scroll_viewport_apply_delta(state, axis, direction, enabled, + input_kind, command, line, 0); + } + if (command == ScrollViewportCommand.page_up) { + return scroll_viewport_apply_delta(state, axis, direction, enabled, + input_kind, command, 0, 0 - page_y); + } + if (command == ScrollViewportCommand.page_down) { + return scroll_viewport_apply_delta(state, axis, direction, enabled, + input_kind, command, 0, page_y); + } + if (command == ScrollViewportCommand.page_left) { + return scroll_viewport_apply_delta(state, axis, direction, enabled, + input_kind, command, 0 - page_x, 0); + } + if (command == ScrollViewportCommand.page_right) { + return scroll_viewport_apply_delta(state, axis, direction, enabled, + input_kind, command, page_x, 0); + } + if (scroll_viewport_axis_valid(axis) == 0 || + scroll_viewport_direction_valid(direction) == 0 || + (enabled != 0 && enabled != 1) || + scroll_state_valid(state.*) != ScrollError.none) { + return scroll_viewport_reject(state.*, input_kind, command, + 0, 0, scroll_state_valid(state.*)); + } + if (enabled == 0) { + return scroll_viewport_reject(state.*, input_kind, command, + 1, 0, ScrollError.none); + } + if (scroll_viewport_axis_has_overflow(state.*, axis) == 0) { + return scroll_viewport_reject(state.*, input_kind, command, + 0, 1, ScrollError.none); + } + let target_x: i64 = state.*.offset_x; + let target_y: i64 = state.*.offset_y; + if (scroll_viewport_axis_y(axis) != 0 && scroll_max_y(state.*) > 0) { + if (command == ScrollViewportCommand.home) { target_y = 0; } + if (command == ScrollViewportCommand.end) { + target_y = scroll_max_y(state.*); + } + } else if (scroll_viewport_axis_x(axis) != 0) { + if (command == ScrollViewportCommand.home) { target_x = 0; } + if (command == ScrollViewportCommand.end) { + target_x = scroll_max_x(state.*); + } + } + let result: ScrollViewportTransition = scroll_viewport_transition( + state.*, input_kind, command); + result.requested_x = target_x - state.*.offset_x; + result.requested_y = target_y - state.*.offset_y; + result.mutation = scroll_to(state, target_x, target_y); + result.accepted = (result.mutation.error == ScrollError.none) as i32; + result.rejected = (result.accepted == 0) as i32; + result.evidence_hash = scroll_viewport_transition_hash(result); + return result; +} + +fn scroll_viewport_key(state: *ScrollState, + axis: ScrollViewportAxis, direction: LayoutDirection, enabled: i32, + density: FlexDensity, + command: ScrollViewportCommand) ScrollViewportTransition { + return scroll_viewport_apply_command(state, axis, direction, enabled, + density, command, ScrollViewportInputKind.key); +} + +fn scroll_viewport_semantic_scroll(state: *ScrollState, + axis: ScrollViewportAxis, direction: LayoutDirection, enabled: i32, + density: FlexDensity, + command: ScrollViewportCommand) ScrollViewportTransition { + return scroll_viewport_apply_command(state, axis, direction, enabled, + density, command, ScrollViewportInputKind.semantic); +} + +fn scroll_viewport_reveal_focus(state: *ScrollState, + axis: ScrollViewportAxis, direction: LayoutDirection, enabled: i32, + target: Rect) ScrollViewportTransition { + if (scroll_viewport_axis_valid(axis) == 0 || + scroll_viewport_direction_valid(direction) == 0 || + (enabled != 0 && enabled != 1) || + scroll_state_valid(state.*) != ScrollError.none || + target.x < 0 || target.y < 0 || target.width < 0 || target.height < 0 || + target.x > unbounded_extent() - target.width || + target.y > unbounded_extent() - target.height) { + return scroll_viewport_reject(state.*, + ScrollViewportInputKind.focus_reveal, + ScrollViewportCommand.home, 0, 0, ScrollError.invalid_target); + } + if (enabled == 0) { + return scroll_viewport_reject(state.*, + ScrollViewportInputKind.focus_reveal, + ScrollViewportCommand.home, 1, 0, ScrollError.none); + } + let target_x: i64 = state.*.offset_x; + let target_y: i64 = state.*.offset_y; + if (scroll_viewport_axis_x(axis) != 0) { + let current_physical: i64 = scroll_visible_rect( + state.*, direction).x; + let physical: i64 = scroll_axis_reveal(current_physical, + state.*.viewport.width, target.x, target.width, + ScrollAlignment.nearest); + target_x = physical; + if (direction == LayoutDirection.rtl) { + target_x = scroll_max_x(state.*) - physical; + } + } + if (scroll_viewport_axis_y(axis) != 0) { + target_y = scroll_axis_reveal(state.*.offset_y, + state.*.viewport.height, target.y, target.height, + ScrollAlignment.nearest); + } + let result: ScrollViewportTransition = scroll_viewport_transition( + state.*, ScrollViewportInputKind.focus_reveal, + ScrollViewportCommand.home); + result.requested_x = target_x - state.*.offset_x; + result.requested_y = target_y - state.*.offset_y; + result.mutation = scroll_to(state, target_x, target_y); + result.accepted = (result.mutation.error == ScrollError.none) as i32; + result.rejected = (result.accepted == 0) as i32; + result.evidence_hash = scroll_viewport_transition_hash(result); + return result; +} + +fn scroll_viewport_target_reveal_hash( + value: ScrollViewportTargetReveal) i64 { + let hash: i64 = scroll_viewport_mix(17, value.id.value); + hash = scroll_viewport_mix(hash, value.id.generation); + hash = scroll_viewport_mix(hash, value.content_bounds.x); + hash = scroll_viewport_mix(hash, value.content_bounds.y); + hash = scroll_viewport_mix(hash, value.content_bounds.width); + hash = scroll_viewport_mix(hash, value.content_bounds.height); + hash = scroll_viewport_mix(hash, value.found as i64); + hash = scroll_viewport_mix(hash, value.focusable as i64); + return scroll_viewport_mix(hash, value.transition.evidence_hash); +} + +// Resolves a stable semantic ID against unscrolled content geometry, then +// mutates ScrollState so the host can rebuild that same ID into the viewport. +// This is the automation/focus bridge for offscreen descendants: discovery is +// semantic, scrolling is stateful, and no pixel guess is required. +fn scroll_viewport_reveal_semantic_id( + state: *ScrollState, + axis: ScrollViewportAxis, direction: LayoutDirection, enabled: i32, + content: *SemanticsTree, + id: NodeKey) ScrollViewportTargetReveal { + let result: ScrollViewportTargetReveal = ScrollViewportTargetReveal{ + .id = id, + .content_bounds = rect(0, 0, 0, 0), + .found = 0, + .focusable = 0, + .transition = scroll_viewport_reject(state.*, + ScrollViewportInputKind.focus_reveal, + ScrollViewportCommand.home, 0, 0, + ScrollError.invalid_target), + .evidence_hash = 0, + }; + let index: i32 = semantics_find_index_borrowed(content, id); + if (index < 0) { + result.evidence_hash = scroll_viewport_target_reveal_hash(result); + return result; + } + let node: SemanticsNode = content.*.nodes.data[index]; + result.found = 1; + result.content_bounds = node.bounds; + result.focusable = (node.disabled == 0 && node.hidden == 0 && + node.has_bounds != 0 && node.bounds.width > 0 && + node.bounds.height > 0 && node.focus_order > 0 && + semantic_actions_has(node.action_mask, + SemanticAction.focus) != 0) as i32; + if (result.focusable != 0) { + result.transition = scroll_viewport_reveal_focus(state, axis, + direction, enabled, node.bounds); + } + result.evidence_hash = scroll_viewport_target_reveal_hash(result); + return result; +} + +fn scroll_viewport_resource_copy(resource: RenderResource) + RenderResource { + return RenderResource{ + .id = resource.id, + .kind = resource.kind, + .payload = render_resource_payload_copy( + resource.payload.data[0..resource.payload.len]), + .revision = resource.revision, + .format_tag = resource.format_tag, + .width = resource.width, + .height = resource.height, + .color_space = resource.color_space, + .content_hash = resource.content_hash, + }; +} + +fn scroll_viewport_stage_display(source: *DisplayList) + ScrollViewportDisplayStage { + let staged: DisplayList = display_list_make_with_resource_limits( + source.*.resources.max_resources, + source.*.resources.max_total_bytes, + source.*.resources.max_payload_bytes); + let result: ScrollViewportDisplayStage = ScrollViewportDisplayStage{ + .display = staged, + .valid = 0, + }; + if (source.*.sealed != 0 || source.*.resources.sealed != 0 || + source.*.last_error != DisplayError.none || + source.*.resources.last_error != RenderResourceError.none || + source.*.error_index != 0 - 1 || source.*.resources.error_id != 0 || + render_resource_store_configuration_valid(source.*.resources) == 0 || + source.*.stack_depth < 0 || source.*.layer_depth < 0) { return result; } + let total: i64 = 0; + let resource_index: i32 = 0; + while (resource_index < source.*.resources.resources.len) { + let resource: RenderResource = + source.*.resources.resources.data[resource_index]; + if (resource.id <= 0 || resource.payload.len <= 0 || + render_resource_hash(resource) != resource.content_hash || + (resource_index > 0 && source.*.resources.resources.data[ + resource_index - 1].id >= resource.id)) { return result; } + push[RenderResource](&result.display.resources.resources, + scroll_viewport_resource_copy(resource)); + total = total + resource.payload.len as i64; + resource_index = resource_index + 1; + } + if (total != source.*.resources.total_bytes || + render_resource_store_hash(&source.*.resources) != + source.*.resources.content_hash) { return result; } + result.display.resources.total_bytes = source.*.resources.total_bytes; + result.display.resources.revision = source.*.resources.revision; + result.display.resources.content_hash = source.*.resources.content_hash; + result.display.resources.last_error = RenderResourceError.none; + result.display.resources.error_id = 0; + result.display.content_hash = display_mix(17, + result.display.resources.content_hash); + result.display.revision = result.display.resources.revision; + let operation_index: i32 = 0; + while (operation_index < source.*.ops.len) { + if (display_list_push(&result.display, + source.*.ops.data[operation_index]) != DisplayError.none) { + return result; + } + operation_index = operation_index + 1; + } + if (result.display.stack_depth != source.*.stack_depth || + result.display.layer_depth != source.*.layer_depth || + result.display.revision != source.*.revision || + result.display.content_hash != source.*.content_hash || + display_list_calculate_hash(&result.display) != + source.*.content_hash) { return result; } + result.valid = 1; + return result; +} + +fn scroll_viewport_stage_add_resource(stage: *DisplayList, + resource: RenderResource) RenderResourceError { + if (render_resource_find_index(&stage.*.resources, resource.id) >= 0) { + return RenderResourceError.duplicate_id; + } + if (stage.*.resources.resources.len >= stage.*.resources.max_resources) { + return RenderResourceError.too_many_resources; + } + if (resource.payload.len as i64 > stage.*.resources.max_payload_bytes) { + return RenderResourceError.payload_limit; + } + if (resource.payload.len as i64 > stage.*.resources.max_total_bytes - + stage.*.resources.total_bytes) { return RenderResourceError.total_limit; } + let copy: RenderResource = scroll_viewport_resource_copy(resource); + let index: i32 = render_resource_insert_index(&stage.*.resources, + resource.id); + push[RenderResource](&stage.*.resources.resources, copy); + let i: i32 = stage.*.resources.resources.len - 1; + while (i > index) { + stage.*.resources.resources.data[i] = + stage.*.resources.resources.data[i - 1]; + i = i - 1; + } + stage.*.resources.resources.data[index] = copy; + stage.*.resources.total_bytes = stage.*.resources.total_bytes + + resource.payload.len as i64; + stage.*.resources.revision = stage.*.resources.revision + + resource.revision + 1; + render_resource_store_rehash(&stage.*.resources); + stage.*.resources.last_error = RenderResourceError.none; + stage.*.resources.error_id = 0; + stage.*.revision = stage.*.ops.len as i64 + stage.*.resources.revision; + stage.*.content_hash = display_list_calculate_hash(stage); + return RenderResourceError.none; +} + +fn scroll_viewport_host_checkpoint(semantics: *SemanticsTree, + hits: *HitTree) ScrollViewportHostCheckpoint { + return ScrollViewportHostCheckpoint{ + .semantics = semantics_tree_checkpoint(semantics), + .hit_length = hits.*.nodes.len, + .hit_revision = hits.*.revision, + .hit_last_error = hits.*.last_error, + .hit_error_node = hits.*.error_node, + }; +} + +fn scroll_viewport_host_rollback(semantics: *SemanticsTree, + hits: *HitTree, + checkpoint: ScrollViewportHostCheckpoint) void { + _ = semantics_tree_rollback(semantics, checkpoint.semantics); + hits.*.nodes.len = checkpoint.hit_length; + hits.*.revision = checkpoint.hit_revision; + hits.*.last_error = checkpoint.hit_last_error; + hits.*.error_node = checkpoint.hit_error_node; +} + +fn scroll_viewport_semantic_spec( + node: @retained_by_return SemanticsNode) SemanticsSpec { + return SemanticsSpec{ + .id = node.id, + .parent = node.parent, + .role = node.role, + .name = node.name.data[0..node.name.len], + .description = node.description.data[0..node.description.len], + .value = node.value.data[0..node.value.len], + .action_mask = node.action_mask, + .focus_order = node.focus_order, + .disabled = node.disabled, + .selected = node.selected, + .focused = node.focused, + .hidden = node.hidden, + .live_region = node.live_region, + .has_range = node.has_range, + .range_min = node.range_min, + .range_max = node.range_max, + .range_value = node.range_value, + .range_step = node.range_step, + .text_length = node.text_length, + .selection_start = node.selection_start, + .selection_end = node.selection_end, + .has_bounds = node.has_bounds, + .bounds = node.bounds, + .labelled_by = node.labelled_by, + .described_by = node.described_by, + .controls = node.controls, + .row_count = node.row_count, + .column_count = node.column_count, + .row_index = node.row_index, + .column_index = node.column_index, + .row_span = node.row_span, + .column_span = node.column_span, + .level = node.level, + .set_size = node.set_size, + .position_in_set = node.position_in_set, + .expanded = node.expanded, + }; +} + +// Find the content root — the first node whose parent is the semantic root +// key. In a well-formed tree this is nodes.data[0], so the common case is +// O(1). The fallback scan guarantees correctness even if a caller prepends +// child nodes before the root. +fn scroll_viewport_semantic_content_root( + content: *SemanticsTree) NodeKey { + if (content.*.nodes.len > 0 && + node_key_equal(content.*.nodes.data[0].parent, + semantic_root_key()) != 0) { + return content.*.nodes.data[0].id; + } + let i: i32 = 0; + while (i < content.*.nodes.len) { + if (node_key_equal(content.*.nodes.data[i].parent, + semantic_root_key()) != 0) { + return content.*.nodes.data[i].id; + } + i = i + 1; + } + return semantic_none_key(); +} + +// Find the hit-tree content root — the first node whose parent is the hit +// root key. Same O(1) common-case rationale as the semantic variant. +fn scroll_viewport_hit_content_root( + content: *HitTree) NodeKey { + if (content.*.nodes.len > 0 && + node_key_equal(content.*.nodes.data[0].parent, + hit_root_key()) != 0) { + return content.*.nodes.data[0].id; + } + let i: i32 = 0; + while (i < content.*.nodes.len) { + if (node_key_equal(content.*.nodes.data[i].parent, + hit_root_key()) != 0) { + return content.*.nodes.data[i].id; + } + i = i + 1; + } + return hit_root_key(); +} + +fn scroll_viewport_hosted_semantic_spec( + node: @retained_by_return SemanticsNode, + spec: ScrollViewportSpec, + geometry: ScrollViewportGeometry, + content_root: NodeKey) SemanticsSpec { + let hosted: SemanticsSpec = scroll_viewport_semantic_spec(node); + if (node_key_equal(hosted.parent, semantic_root_key()) != 0 || + node_key_equal(hosted.parent, content_root) != 0) { + hosted.parent = spec.id; + } + if (hosted.focus_order > 0) { + hosted.focus_order = hosted.focus_order + + spec.content_focus_order_offset; + } + if (hosted.has_bounds != 0) { + let shifted: Rect = rect( + hosted.bounds.x + geometry.content_transform.translate_x, + hosted.bounds.y + geometry.content_transform.translate_y, + hosted.bounds.width, hosted.bounds.height); + // Preserve complete transformed bounds for every semantic descendant. + // Ancestor viewport geometry supplies the clip to native adapters; + // complete bounds keep hosted ID -> content-rect reveal reversible. + // Normal pointer hits are still intersected with the viewport below. + hosted.bounds = shifted; + } + if (spec.enabled == 0) { + hosted.disabled = 1; + hosted.focused = 0; + hosted.action_mask = 0; + hosted.focus_order = 0; + } + return hosted; +} + +fn scroll_viewport_semantic_descendant(tree: *SemanticsTree, + id: NodeKey, ancestor: NodeKey) i32 { + let index: i32 = semantics_find_index_borrowed(tree, id); + let examined: i32 = 0; + while (index >= 0 && examined <= tree.*.nodes.len) { + let parent: NodeKey = tree.*.nodes.data[index].parent; + if (node_key_equal(parent, ancestor) != 0) { return 1; } + if (node_key_equal(parent, semantic_root_key()) != 0) { return 0; } + index = semantics_find_index_borrowed(tree, parent); + examined = examined + 1; + } + return 0; +} + +// Hosted variant for composition owners that have already released the +// source artifact. Full transformed semantic bounds are inverted through the +// exact ScrollViewportGeometry; callers do not retain a second target map. +fn scroll_viewport_reveal_hosted_semantic_id( + state: *ScrollState, + axis: ScrollViewportAxis, direction: LayoutDirection, enabled: i32, + hosted: *SemanticsTree, + geometry: ScrollViewportGeometry, + viewport_id: NodeKey, id: NodeKey) ScrollViewportTargetReveal { + let result: ScrollViewportTargetReveal = ScrollViewportTargetReveal{ + .id = id, + .content_bounds = rect(0, 0, 0, 0), + .found = 0, + .focusable = 0, + .transition = scroll_viewport_reject(state.*, + ScrollViewportInputKind.focus_reveal, + ScrollViewportCommand.home, 0, 0, + ScrollError.invalid_target), + .evidence_hash = 0, + }; + if (geometry.valid == 0 || + node_key_equal(id, viewport_id) != 0 || + node_key_equal(id, + scroll_viewport_horizontal_range_id(viewport_id)) != 0 || + node_key_equal(id, + scroll_viewport_vertical_range_id(viewport_id)) != 0 || + scroll_viewport_semantic_descendant(hosted, id, + viewport_id) == 0) { + result.evidence_hash = scroll_viewport_target_reveal_hash(result); + return result; + } + let index: i32 = semantics_find_index_borrowed(hosted, id); + let node: SemanticsNode = hosted.*.nodes.data[index]; + result.found = 1; + result.content_bounds = rect( + node.bounds.x - geometry.content_transform.translate_x, + node.bounds.y - geometry.content_transform.translate_y, + node.bounds.width, node.bounds.height); + result.focusable = (node.disabled == 0 && node.hidden == 0 && + node.has_bounds != 0 && node.bounds.width > 0 && + node.bounds.height > 0 && node.focus_order > 0 && + semantic_actions_has(node.action_mask, + SemanticAction.focus) != 0) as i32; + if (result.focusable != 0) { + result.transition = scroll_viewport_reveal_focus(state, axis, + direction, enabled, result.content_bounds); + } + result.evidence_hash = scroll_viewport_target_reveal_hash(result); + return result; +} + +fn scroll_viewport_hosted_hit_node(source: HitNode, + spec: ScrollViewportSpec, + geometry: ScrollViewportGeometry, + content_root: NodeKey) HitNode { + let hosted: HitNode = source; + if (node_key_equal(hosted.parent, hit_root_key()) != 0 || + node_key_equal(hosted.parent, content_root) != 0) { + hosted.parent = spec.id; + } + let local_visible: Rect = rect( + geometry.visible_content_bounds.x - source.transform.translate_x, + geometry.visible_content_bounds.y - source.transform.translate_y, + geometry.visible_content_bounds.width, + geometry.visible_content_bounds.height); + let source_clip: Rect = source.bounds; + if (source.has_clip != 0) { source_clip = source.clip; } + let clipped: Rect = scroll_viewport_intersection(source_clip, + local_visible); + hosted.transform.translate_x = source.transform.translate_x + + geometry.content_transform.translate_x; + hosted.transform.translate_y = source.transform.translate_y + + geometry.content_transform.translate_y; + hosted.z_order = spec.z_order + source.z_order + 1; + hosted.has_clip = 1; + if (scroll_viewport_rect_nonempty(clipped) != 0) { + hosted.clip = local_visible; + hosted.enabled = (source.enabled != 0 && spec.enabled != 0) as i32; + hosted.focusable = (source.focusable != 0 && + hosted.enabled != 0) as i32; + } else { + hosted.clip = source.bounds; + hosted.enabled = 0; + hosted.focusable = 0; + } + return hosted; +} + +fn scroll_viewport_content_hit_transform_supported(node: HitNode) i32 { + return (node.transform.scale_x == unit_scale() && + node.transform.scale_y == unit_scale() && + node.transform.skew_x == 0 && node.transform.skew_y == 0) as i32; +} + +fn scroll_viewport_content_valid(content_display: *DisplayList, + content_semantics: *SemanticsTree, + content_hits: *HitTree) i32 { + if (display_list_verify(content_display) != DisplayError.none || + content_display.*.stack_depth != 0 || + content_display.*.layer_depth != 0 || + content_semantics.*.last_error != SemanticsError.none || + content_hits.*.last_error != HitTreeError.none) { return 0; } + let i: i32 = 0; + while (i < content_hits.*.nodes.len) { + if (scroll_viewport_content_hit_transform_supported( + content_hits.*.nodes.data[i]) == 0) { return 0; } + i = i + 1; + } + return 1; +} + +fn scroll_viewport_focus_order_exists(tree: *SemanticsTree, + order: i64) i32 { + let i: i32 = 0; + while (i < tree.*.nodes.len) { + if (tree.*.nodes.data[i].focus_order == order) { return 1; } + i = i + 1; + } + return 0; +} + +fn scroll_viewport_capacity_error(spec: ScrollViewportSpec, + geometry: ScrollViewportGeometry, + content_display: *DisplayList, + content_semantics: *SemanticsTree, + content_hits: *HitTree) ScrollViewportCapacityError { + let display_count: i32 = content_display.*.ops.len + 4 + + geometry.horizontal_track_visible * 2 + + geometry.vertical_track_visible * 2 + + (spec.focused != 0 && spec.focus_visible != 0 && + spec.enabled != 0) as i32; + if (display_count > spec.limits.display_operations) { + return ScrollViewportCapacityError.display_operations; + } + if (content_hits.*.nodes.len + 1 > spec.limits.hit_nodes) { + return ScrollViewportCapacityError.hit_nodes; + } + let axis_semantics: i32 = geometry.horizontal_track_visible + + geometry.vertical_track_visible; + if (content_semantics.*.nodes.len + 1 + axis_semantics > + spec.limits.semantic_nodes) { + return ScrollViewportCapacityError.semantic_nodes; + } + return ScrollViewportCapacityError.none; +} + +fn scroll_viewport_empty_artifact(spec: ScrollViewportSpec, + state: ScrollState) ScrollViewportArtifact { + return ScrollViewportArtifact{ + .id = spec.id, + .geometry = scroll_viewport_geometry(spec, state), + .state_hash = scroll_state_hash(state), + .scrollable = 0, + .enabled = spec.enabled, + .focused = spec.focused, + .focus_ring_visible = 0, + .track_token = SemanticColorToken.surface_inset, + .thumb_token = SemanticColorToken.border_emphasis, + .focus_token = SemanticColorToken.focus, + .display_op_start = 0 - 1, + .display_op_end = 0 - 1, + .display_operations_added = 0, + .display_resources_added = 0, + .hit_node_start = 0 - 1, + .hit_node_end = 0 - 1, + .semantic_node_start = 0 - 1, + .semantic_node_end = 0 - 1, + .evidence_hash = 0, + }; +} + +fn scroll_viewport_result(spec: ScrollViewportSpec, + state: ScrollState) ScrollViewportBuildResult { + return ScrollViewportBuildResult{ + .artifact = scroll_viewport_empty_artifact(spec, state), + .error = ScrollViewportBuildError.none, + .capacity_error = ScrollViewportCapacityError.none, + .display_error = DisplayError.none, + .resource_error = RenderResourceError.none, + .hit_error = HitTreeError.none, + .semantics_error = SemanticsError.none, + }; +} + +fn scroll_viewport_artifact_hash(spec: ScrollViewportSpec, + artifact: ScrollViewportArtifact) i64 { + let hash: i64 = scroll_viewport_mix(17, artifact.id.value); + hash = scroll_viewport_mix(hash, artifact.id.generation); + hash = scroll_viewport_mix(hash, artifact.geometry.evidence_hash); + hash = scroll_viewport_mix(hash, artifact.state_hash); + hash = scroll_viewport_mix(hash, spec.axis as i64); + hash = scroll_viewport_mix(hash, spec.direction as i64); + hash = scroll_viewport_mix(hash, spec.density as i64); + hash = scroll_viewport_mix(hash, artifact.scrollable as i64); + hash = scroll_viewport_mix(hash, artifact.enabled as i64); + hash = scroll_viewport_mix(hash, artifact.focused as i64); + hash = scroll_viewport_mix(hash, artifact.focus_ring_visible as i64); + hash = scroll_viewport_mix(hash, artifact.track_token as i64); + hash = scroll_viewport_mix(hash, artifact.thumb_token as i64); + hash = scroll_viewport_mix(hash, artifact.display_operations_added as i64); + hash = scroll_viewport_mix(hash, artifact.display_resources_added as i64); + hash = scroll_viewport_mix(hash, + artifact.hit_node_end - artifact.hit_node_start); + return scroll_viewport_mix(hash, + artifact.semantic_node_end - artifact.semantic_node_start); +} + +fn scroll_viewport_add_root_semantic(spec: ScrollViewportSpec, + state: ScrollState, geometry: ScrollViewportGeometry, + destination: *SemanticsTree) SemanticsError { + let semantic: SemanticsSpec = semantics_spec(spec.id, + spec.semantic_parent, SemanticRole.group, spec.name); + semantic.description = spec.description; + semantic.value = spec.value; + semantic.has_bounds = 1; + semantic.bounds = geometry.viewport_bounds; + semantic.disabled = (spec.enabled == 0) as i32; + semantic.focused = spec.focused; + semantic.focus_order = spec.focus_order; + let scrollable: i32 = scroll_viewport_axis_has_overflow(state, + spec.axis); + if (spec.enabled != 0 && scrollable != 0) { + semantic.action_mask = semantic_actions(SemanticAction.scroll); + } + if (spec.enabled != 0 && spec.focus_order > 0) { + semantic.action_mask = semantic_actions_add(semantic.action_mask, + SemanticAction.focus); + } + if (scrollable != 0) { + semantic.has_range = 1; + semantic.range_min = 0; + semantic.range_step = unit_scale(); + if (scroll_viewport_axis_y(spec.axis) != 0 && + geometry.vertical_max > 0) { + semantic.range_max = geometry.vertical_max; + semantic.range_value = geometry.vertical_value; + } else { + semantic.range_max = geometry.horizontal_max; + semantic.range_value = geometry.horizontal_value; + } + } + return semantics_add(destination, semantic); +} + +fn scroll_viewport_add_axis_semantic(spec: ScrollViewportSpec, + geometry: ScrollViewportGeometry, horizontal: i32, + destination: *SemanticsTree) SemanticsError { + let id: NodeKey = scroll_viewport_vertical_range_id(spec.id); + let name: []u8 = "Vertical scroll position"; + let bounds: Rect = geometry.vertical_track_bounds; + let maximum: i64 = geometry.vertical_max; + let value: i64 = geometry.vertical_value; + if (horizontal != 0) { + id = scroll_viewport_horizontal_range_id(spec.id); + name = "Horizontal scroll position"; + bounds = geometry.horizontal_track_bounds; + maximum = geometry.horizontal_max; + value = geometry.horizontal_value; + } + let semantic: SemanticsSpec = semantics_spec(id, spec.id, + SemanticRole.slider, name); + semantic.description = "Read and mutate through the viewport scroll contract"; + semantic.value = spec.value; + semantic.has_bounds = 1; + semantic.bounds = bounds; + semantic.disabled = (spec.enabled == 0) as i32; + semantic.has_range = 1; + semantic.range_min = 0; + semantic.range_max = maximum; + semantic.range_value = value; + semantic.range_step = unit_scale(); + if (spec.enabled != 0) { + semantic.action_mask = semantic_actions(SemanticAction.scroll); + } + return semantics_add(destination, semantic); +} + +fn scroll_viewport_add_semantics(spec: ScrollViewportSpec, + state: ScrollState, geometry: ScrollViewportGeometry, + content: *SemanticsTree, + destination: *SemanticsTree) SemanticsError { + let error: SemanticsError = scroll_viewport_add_root_semantic( + spec, state, geometry, destination); + if (error != SemanticsError.none) { return error; } + if (geometry.horizontal_track_visible != 0) { + error = scroll_viewport_add_axis_semantic(spec, geometry, 1, + destination); + if (error != SemanticsError.none) { return error; } + } + if (geometry.vertical_track_visible != 0) { + error = scroll_viewport_add_axis_semantic(spec, geometry, 0, + destination); + if (error != SemanticsError.none) { return error; } + } + let content_root: NodeKey = + scroll_viewport_semantic_content_root(content); + let i: i32 = 0; + while (i < content.*.nodes.len) { + let hosted: SemanticsSpec = scroll_viewport_hosted_semantic_spec( + content.*.nodes.data[i], spec, geometry, content_root); + error = semantics_add(destination, hosted); + if (error != SemanticsError.none) { return error; } + i = i + 1; + } + return SemanticsError.none; +} + +fn scroll_viewport_add_hits(spec: ScrollViewportSpec, + geometry: ScrollViewportGeometry, + content: *HitTree, + destination: *HitTree) HitTreeError { + let root: HitNode = hit_node(spec.id, spec.hit_parent, + geometry.viewport_bounds); + root.has_clip = 1; + root.clip = geometry.clip_bounds; + root.z_order = spec.z_order; + root.enabled = spec.enabled; + root.focusable = (spec.enabled != 0 && spec.focus_order > 0) as i32; + let error: HitTreeError = hit_tree_add(destination, root); + if (error != HitTreeError.none) { return error; } + let content_root: NodeKey = + scroll_viewport_hit_content_root(content); + let i: i32 = 0; + while (i < content.*.nodes.len) { + let hosted: HitNode = scroll_viewport_hosted_hit_node( + content.*.nodes.data[i], spec, geometry, content_root); + error = hit_tree_add(destination, hosted); + if (error != HitTreeError.none) { return error; } + i = i + 1; + } + return HitTreeError.none; +} + +fn scroll_viewport_push(stage: *DisplayList, + op: DisplayOp) DisplayError { + return display_list_push(stage, op); +} + +fn scroll_viewport_add_display(spec: ScrollViewportSpec, + geometry: ScrollViewportGeometry, + content: *DisplayList, + stage: *DisplayList) ScrollViewportBuildResult { + let state: ScrollState = scroll_state( + size(spec.bounds.width, spec.bounds.height), + size(spec.bounds.width, spec.bounds.height)); + let result: ScrollViewportBuildResult = scroll_viewport_result(spec, state); + let resource_index: i32 = 0; + while (resource_index < content.*.resources.resources.len) { + let resource_error: RenderResourceError = + scroll_viewport_stage_add_resource(stage, + content.*.resources.resources.data[resource_index]); + if (resource_error != RenderResourceError.none) { + result.error = ScrollViewportBuildError.display_error; + result.resource_error = resource_error; + return result; + } + resource_index = resource_index + 1; + } + let op: DisplayOp = display_op(DisplayOpKind.save, spec.id); + let display_error: DisplayError = scroll_viewport_push(stage, op); + if (display_error != DisplayError.none) { + result.error = ScrollViewportBuildError.display_error; + result.display_error = display_error; + return result; + } + op = display_op(DisplayOpKind.clip_rect, spec.id); + op.bounds = geometry.clip_bounds; + display_error = scroll_viewport_push(stage, op); + if (display_error != DisplayError.none) { + result.error = ScrollViewportBuildError.display_error; + result.display_error = display_error; + return result; + } + op = display_op(DisplayOpKind.concat_transform, spec.id); + op.transform = geometry.content_transform; + display_error = scroll_viewport_push(stage, op); + if (display_error != DisplayError.none) { + result.error = ScrollViewportBuildError.display_error; + result.display_error = display_error; + return result; + } + let operation_index: i32 = 0; + while (operation_index < content.*.ops.len) { + display_error = scroll_viewport_push(stage, + content.*.ops.data[operation_index]); + if (display_error != DisplayError.none) { + result.error = ScrollViewportBuildError.display_error; + result.display_error = display_error; + return result; + } + operation_index = operation_index + 1; + } + op = display_op(DisplayOpKind.restore, spec.id); + display_error = scroll_viewport_push(stage, op); + if (display_error != DisplayError.none) { + result.error = ScrollViewportBuildError.display_error; + result.display_error = display_error; + return result; + } + if (spec.focused != 0 && spec.focus_visible != 0 && spec.enabled != 0) { + op = display_op(DisplayOpKind.stroke_rect, spec.id); + op.bounds = geometry.viewport_bounds; + op.paint = semantic_color_with_alpha( + SemanticColorToken.focus, 52000); + op.paint.stroke_width = 2 * unit_scale(); + display_error = scroll_viewport_push(stage, op); + if (display_error != DisplayError.none) { + result.error = ScrollViewportBuildError.display_error; + result.display_error = display_error; + return result; + } + } + let radius: i64 = flex_spacing(FlexSpacingToken.tiny, + spec.density) / 2; + if (geometry.horizontal_track_visible != 0) { + op = display_op(DisplayOpKind.fill_rounded_rect, spec.id); + op.bounds = geometry.horizontal_track_bounds; + op.parameter = radius; + op.paint = semantic_color_with_alpha( + SemanticColorToken.surface_inset, 52000); + display_error = scroll_viewport_push(stage, op); + if (display_error != DisplayError.none) { + result.error = ScrollViewportBuildError.display_error; + result.display_error = display_error; + return result; + } + op = display_op(DisplayOpKind.fill_rounded_rect, spec.id); + op.bounds = geometry.horizontal_thumb_bounds; + op.parameter = radius; + op.paint = semantic_color_with_alpha( + SemanticColorToken.border_emphasis, 52000); + display_error = scroll_viewport_push(stage, op); + if (display_error != DisplayError.none) { + result.error = ScrollViewportBuildError.display_error; + result.display_error = display_error; + return result; + } + } + if (geometry.vertical_track_visible != 0) { + op = display_op(DisplayOpKind.fill_rounded_rect, spec.id); + op.bounds = geometry.vertical_track_bounds; + op.parameter = radius; + op.paint = semantic_color_with_alpha( + SemanticColorToken.surface_inset, 52000); + display_error = scroll_viewport_push(stage, op); + if (display_error != DisplayError.none) { + result.error = ScrollViewportBuildError.display_error; + result.display_error = display_error; + return result; + } + op = display_op(DisplayOpKind.fill_rounded_rect, spec.id); + op.bounds = geometry.vertical_thumb_bounds; + op.parameter = radius; + op.paint = semantic_color_with_alpha( + SemanticColorToken.border_emphasis, 52000); + display_error = scroll_viewport_push(stage, op); + if (display_error != DisplayError.none) { + result.error = ScrollViewportBuildError.display_error; + result.display_error = display_error; + return result; + } + } + return result; +} + +fn scroll_viewport_resource_preflight(content: *DisplayList, + destination: *DisplayList) RenderResourceError { + if (destination.*.resources.resources.len > + destination.*.resources.max_resources - + content.*.resources.resources.len) { + return RenderResourceError.too_many_resources; + } + if (content.*.resources.total_bytes > + destination.*.resources.max_total_bytes - + destination.*.resources.total_bytes) { + return RenderResourceError.total_limit; + } + let i: i32 = 0; + while (i < content.*.resources.resources.len) { + let resource: RenderResource = content.*.resources.resources.data[i]; + if (render_resource_find_index(&destination.*.resources, + resource.id) >= 0) { return RenderResourceError.duplicate_id; } + if (resource.payload.len as i64 > + destination.*.resources.max_payload_bytes) { + return RenderResourceError.payload_limit; + } + i = i + 1; + } + return RenderResourceError.none; +} + +fn scroll_viewport_preflight_nodes(spec: ScrollViewportSpec, + geometry: ScrollViewportGeometry, + content_semantics: *SemanticsTree, + content_hits: *HitTree, + destination_semantics: *SemanticsTree, + destination_hits: *HitTree) ScrollViewportBuildResult { + let state: ScrollState = scroll_state( + size(spec.bounds.width, spec.bounds.height), + size(spec.bounds.width, spec.bounds.height)); + let result: ScrollViewportBuildResult = scroll_viewport_result(spec, state); + if (node_key_equal(spec.semantic_parent, semantic_root_key()) == 0 && + semantics_find_index(destination_semantics.*, + spec.semantic_parent) < 0) { + result.error = ScrollViewportBuildError.semantics_error; + result.semantics_error = SemanticsError.missing_parent; + return result; + } + if (node_key_equal(spec.hit_parent, hit_root_key()) == 0 && + hit_tree_find_index(destination_hits.*, + spec.hit_parent) < 0) { + result.error = ScrollViewportBuildError.hit_error; + result.hit_error = HitTreeError.missing_parent; + return result; + } + if (semantics_find_index(destination_semantics.*, spec.id) >= 0) { + result.error = ScrollViewportBuildError.semantics_error; + result.semantics_error = SemanticsError.duplicate_id; + return result; + } + let horizontal_range: NodeKey = + scroll_viewport_horizontal_range_id(spec.id); + let vertical_range: NodeKey = + scroll_viewport_vertical_range_id(spec.id); + if ((geometry.horizontal_track_visible != 0 && + semantics_find_index(destination_semantics.*, + horizontal_range) >= 0) || + (geometry.vertical_track_visible != 0 && + semantics_find_index(destination_semantics.*, + vertical_range) >= 0)) { + result.error = ScrollViewportBuildError.semantics_error; + result.semantics_error = SemanticsError.duplicate_id; + return result; + } + if (hit_tree_find_index(destination_hits.*, spec.id) >= 0) { + result.error = ScrollViewportBuildError.hit_error; + result.hit_error = HitTreeError.duplicate_id; + return result; + } + if (spec.focus_order > 0 && scroll_viewport_focus_order_exists( + destination_semantics, spec.focus_order) != 0) { + result.error = ScrollViewportBuildError.semantics_error; + result.semantics_error = SemanticsError.duplicate_focus_order; + return result; + } + let focused_count: i32 = spec.focused; + let content_root: NodeKey = + scroll_viewport_semantic_content_root(content_semantics); + let semantic_index: i32 = 0; + while (semantic_index < content_semantics.*.nodes.len) { + let node: SemanticsNode = + content_semantics.*.nodes.data[semantic_index]; + if (node_key_equal(node.id, spec.id) != 0 || + (geometry.horizontal_track_visible != 0 && + node_key_equal(node.id, horizontal_range) != 0) || + (geometry.vertical_track_visible != 0 && + node_key_equal(node.id, vertical_range) != 0) || + semantics_find_index(destination_semantics.*, node.id) >= 0) { + result.error = ScrollViewportBuildError.semantics_error; + result.semantics_error = SemanticsError.duplicate_id; + return result; + } + if (node.focus_order > 0) { + if (node.focus_order > scroll_viewport_focus_order_limit() - + spec.content_focus_order_offset) { + result.error = ScrollViewportBuildError.invalid_spec; + return result; + } + let order: i64 = node.focus_order + + spec.content_focus_order_offset; + if (order == spec.focus_order || + scroll_viewport_focus_order_exists( + destination_semantics, order) != 0) { + result.error = ScrollViewportBuildError.semantics_error; + result.semantics_error = SemanticsError.duplicate_focus_order; + return result; + } + } + if (node.focused != 0) { + focused_count = focused_count + 1; + let hosted: SemanticsSpec = scroll_viewport_hosted_semantic_spec( + node, spec, geometry, content_root); + if (hosted.hidden != 0 || hosted.disabled != 0) { + result.error = ScrollViewportBuildError.invalid_content; + return result; + } + } + semantic_index = semantic_index + 1; + } + if (focused_count > 1) { + result.error = ScrollViewportBuildError.invalid_content; + return result; + } + let hit_index: i32 = 0; + while (hit_index < content_hits.*.nodes.len) { + let node: HitNode = content_hits.*.nodes.data[hit_index]; + if (node_key_equal(node.id, spec.id) != 0 || + hit_tree_find_index(destination_hits.*, node.id) >= 0) { + result.error = ScrollViewportBuildError.hit_error; + result.hit_error = HitTreeError.duplicate_id; + return result; + } + if (node.z_order < 0 || node.z_order > + scroll_viewport_focus_order_limit() - spec.z_order - 1) { + result.error = ScrollViewportBuildError.invalid_content; + return result; + } + hit_index = hit_index + 1; + } + return result; +} + +// Hosts already-built content as one failure-atomic contribution. Content +// display operations use content coordinates. Content semantic bounds and hit +// transforms use that same coordinate space; root parents are reparented under +// the viewport while stable child IDs and relationships are preserved. +fn scroll_viewport_contribute(spec: ScrollViewportSpec, + state: ScrollState, + content_display: *DisplayList, + content_semantics: *SemanticsTree, + content_hits: *HitTree, + destination_display: *DisplayList, + destination_semantics: *SemanticsTree, + destination_hits: *HitTree) ScrollViewportBuildResult { + let result: ScrollViewportBuildResult = scroll_viewport_result(spec, state); + if (scroll_viewport_spec_valid(spec) == 0) { + result.error = ScrollViewportBuildError.invalid_spec; + return result; + } + if (scroll_state_valid(state) != ScrollError.none || + state.viewport.width != spec.bounds.width || + state.viewport.height != spec.bounds.height) { + result.error = ScrollViewportBuildError.invalid_state; + return result; + } + let geometry: ScrollViewportGeometry = scroll_viewport_geometry(spec, state); + if (geometry.valid == 0) { + result.error = ScrollViewportBuildError.invalid_state; + return result; + } + if (scroll_viewport_content_valid(content_display, + content_semantics, content_hits) == 0) { + result.error = ScrollViewportBuildError.invalid_content; + return result; + } + if (destination_display.*.sealed != 0 || + destination_display.*.resources.sealed != 0 || + destination_display.*.last_error != DisplayError.none || + destination_display.*.resources.last_error != RenderResourceError.none) { + result.error = ScrollViewportBuildError.invalid_destination; + return result; + } + let capacity: ScrollViewportCapacityError = scroll_viewport_capacity_error( + spec, geometry, content_display, content_semantics, content_hits); + if (capacity != ScrollViewportCapacityError.none) { + result.error = ScrollViewportBuildError.capacity_error; + result.capacity_error = capacity; + return result; + } + let resource_preflight: RenderResourceError = + scroll_viewport_resource_preflight(content_display, + destination_display); + if (resource_preflight != RenderResourceError.none) { + result.resource_error = resource_preflight; + if (resource_preflight == RenderResourceError.duplicate_id) { + result.error = ScrollViewportBuildError.resource_collision; + } else { + result.error = ScrollViewportBuildError.capacity_error; + result.capacity_error = + ScrollViewportCapacityError.display_resources; + } + return result; + } + let node_preflight: ScrollViewportBuildResult = + scroll_viewport_preflight_nodes(spec, geometry, + content_semantics, content_hits, + destination_semantics, destination_hits); + if (node_preflight.error != ScrollViewportBuildError.none) { + return node_preflight; + } + + let staged: ScrollViewportDisplayStage = + scroll_viewport_stage_display(destination_display); + if (staged.valid == 0) { + display_list_free(&staged.display); + result.error = ScrollViewportBuildError.invalid_destination; + return result; + } + let display_before: i32 = staged.display.ops.len; + let resources_before: i32 = staged.display.resources.resources.len; + let staged_result: ScrollViewportBuildResult = scroll_viewport_add_display( + spec, geometry, content_display, &staged.display); + if (staged_result.error != ScrollViewportBuildError.none) { + result.error = staged_result.error; + result.display_error = staged_result.display_error; + result.resource_error = staged_result.resource_error; + display_list_free(&staged.display); + return result; + } + + let checkpoint: ScrollViewportHostCheckpoint = + scroll_viewport_host_checkpoint(destination_semantics, + destination_hits); + let semantics_error: SemanticsError = scroll_viewport_add_semantics( + spec, state, geometry, content_semantics, destination_semantics); + if (semantics_error != SemanticsError.none) { + scroll_viewport_host_rollback(destination_semantics, + destination_hits, checkpoint); + display_list_free(&staged.display); + result.error = ScrollViewportBuildError.semantics_error; + result.semantics_error = semantics_error; + return result; + } + let hit_error: HitTreeError = scroll_viewport_add_hits(spec, geometry, + content_hits, destination_hits); + if (hit_error != HitTreeError.none) { + scroll_viewport_host_rollback(destination_semantics, + destination_hits, checkpoint); + display_list_free(&staged.display); + result.error = ScrollViewportBuildError.hit_error; + result.hit_error = hit_error; + return result; + } + + let previous: DisplayList = destination_display.*; + destination_display.* = staged.display; + display_list_free(&previous); + result.artifact = ScrollViewportArtifact{ + .id = spec.id, + .geometry = geometry, + .state_hash = scroll_state_hash(state), + .scrollable = scroll_viewport_axis_has_overflow(state, spec.axis), + .enabled = spec.enabled, + .focused = spec.focused, + .focus_ring_visible = (spec.focused != 0 && + spec.focus_visible != 0 && spec.enabled != 0) as i32, + .track_token = SemanticColorToken.surface_inset, + .thumb_token = SemanticColorToken.border_emphasis, + .focus_token = SemanticColorToken.focus, + .display_op_start = display_before, + .display_op_end = destination_display.*.ops.len, + .display_operations_added = destination_display.*.ops.len - + display_before, + .display_resources_added = + destination_display.*.resources.resources.len - resources_before, + .hit_node_start = checkpoint.hit_length, + .hit_node_end = destination_hits.*.nodes.len, + .semantic_node_start = checkpoint.semantics.length, + .semantic_node_end = destination_semantics.*.nodes.len, + .evidence_hash = 0, + }; + result.artifact.evidence_hash = scroll_viewport_artifact_hash( + spec, result.artifact); + return result; +} diff --git a/src/components/segmented_control.zag b/src/components/segmented_control.zag new file mode 100644 index 0000000..4160c63 --- /dev/null +++ b/src/components/segmented_control.zag @@ -0,0 +1,1164 @@ +@import("std:list") +@import("../core/geometry.zag") +@import("../core/view_contract.zag") +@import("../design/tokens.zag") +@import("../input/hit_test.zag") +@import("../layout/adaptive.zag") +@import("../render/display_list.zag") +@import("../semantics/semantics.zag") + +// SegmentedControl is one retained single-selection group. The application owns +// SegmentedControlModel; events flow through segmented_control_reduce and the +// resulting model flows back into emission. Options are caller-owned for the +// duration of segmented_control_emit and are never retained by the component. +// Roving tab-stop position, semantic focus ownership, and focus-ring modality +// are separate model facts; construction never fabricates native focus. + +fn segmented_control_no_index() i32 { return 0 - 1; } +fn segmented_control_maximum_segments() i32 { return 12; } +fn segmented_control_label_byte_limit() i32 { return 4096; } +fn segmented_control_description_byte_limit() i32 { return 16384; } + +enum SegmentedControlEventKind { + pointer_enter, + pointer_leave, + pointer_down, + pointer_up_inside, + pointer_cancel, + focus_target, + focus_lost, + key_previous, + key_next, + key_home, + key_end, + activate_focused, + select_target, + disable_target, + enable_target, +} + +enum SegmentedControlPurpose { view_switcher, value_picker } + +enum SegmentedControlBuildError { + none, + invalid_spec, + capacity_error, + display_error, + hit_error, + semantics_error, +} + +enum SegmentedControlCapacityError { + none, + display_operations, + hit_nodes, + semantic_nodes, +} + +struct SegmentedControlOption { + id: NodeKey, + label: []u8, + description: []u8, +} + +struct SegmentedControlModel { + segment_count: i32, + selected_index: i32, + roving_index: i32, + has_focus: i32, + focus_visible: i32, + hovered_index: i32, + pressed_index: i32, + enabled_mask: i64, +} + +struct SegmentedControlEvent { + kind: SegmentedControlEventKind, + target_index: i32, +} + +struct SegmentedControlTransition { + model: SegmentedControlModel, + previous_selected_index: i32, + selected_index: i32, + selection_changed: i32, + roving_changed: i32, + focus_changed: i32, + activated: i32, + rejected: i32, +} + +struct SegmentedControlBuildLimits { + display_operations: i32, + hit_nodes: i32, + semantic_nodes: i32, +} + +struct SegmentedControlBuildBudget { + limits: SegmentedControlBuildLimits, + display_operations: i32, + hit_nodes: i32, + semantic_nodes: i32, +} + +struct SegmentedControlSpec { + id: NodeKey, + parent: NodeKey, + bounds: Rect, + name: []u8, + description: []u8, + focus_order_base: i64, + z_order: i64, + density: FlexDensity, + purpose: SegmentedControlPurpose, + model: SegmentedControlModel, + limits: SegmentedControlBuildLimits, +} + +struct SegmentedControlResolvedStyle { + fill_token: SemanticColorToken, + border_token: SemanticColorToken, + label_color_token: SemanticColorToken, + focus_token: SemanticColorToken, + marker_token: SemanticColorToken, + fill_alpha: i64, + border_alpha: i64, + radius: i64, + elevation: SemanticElevationToken, + selected: i32, + hovered: i32, + focused: i32, + has_focus: i32, + focus_visible: i32, + pressed: i32, + disabled: i32, +} + +struct SegmentedControlArtifact { + id: NodeKey, + bounds: Rect, + segment_count: i32, + selected_index: i32, + selected_id: NodeKey, + selected_bounds: Rect, + roving_index: i32, + has_focus: i32, + focus_visible: i32, + selected_marker_visible: i32, + rail_fill_token: SemanticColorToken, + rail_border_token: SemanticColorToken, + rail_radius_token: SemanticRadiusToken, + rail_elevation_token: SemanticElevationToken, + label_type_token: SemanticTypeToken, + purpose: SegmentedControlPurpose, + group_role: SemanticRole, + display_op_start: i32, + display_op_end: i32, + group_hit_node_index: i32, + group_semantics_node_index: i32, + evidence_hash: i64, +} + +struct SegmentedControlBuildResult { + artifact: SegmentedControlArtifact, + error: SegmentedControlBuildError, + capacity_error: SegmentedControlCapacityError, + display_error: DisplayError, + hit_error: HitTreeError, + semantics_error: SemanticsError, +} + +struct SegmentedControlCheckpoint { + display_length: i32, + display_revision: i64, + display_hash: i64, + display_last_error: DisplayError, + display_error_index: i32, + hit_length: i32, + hit_revision: i64, + hit_last_error: HitTreeError, + hit_error_node: NodeKey, + semantics: SemanticsCheckpoint, +} + +fn segmented_control_bit(index: i32) i64 { + return (1 as i64) << index; +} + +fn segmented_control_index_in_range(model: SegmentedControlModel, + index: i32) i32 { + return (index >= 0 && index < model.segment_count) as i32; +} + +fn segmented_control_index_enabled(model: SegmentedControlModel, + index: i32) i32 { + if (segmented_control_index_in_range(model, index) == 0) { return 0; } + return ((model.enabled_mask & segmented_control_bit(index)) != 0) as i32; +} + +fn segmented_control_all_enabled_mask(segment_count: i32) i64 { + let mask: i64 = 0; + let index: i32 = 0; + while (index < segment_count && index < segmented_control_maximum_segments()) { + mask = mask | segmented_control_bit(index); + index = index + 1; + } + return mask; +} + +fn segmented_control_model(segment_count: i32, + selected_index: i32) SegmentedControlModel { + return SegmentedControlModel{ + .segment_count = segment_count, + .selected_index = selected_index, + .roving_index = selected_index, + .has_focus = 0, + .focus_visible = 0, + .hovered_index = segmented_control_no_index(), + .pressed_index = segmented_control_no_index(), + .enabled_mask = segmented_control_all_enabled_mask(segment_count), + }; +} + +fn segmented_control_model_valid(model: SegmentedControlModel) i32 { + if (model.segment_count < 2 || + model.segment_count > segmented_control_maximum_segments()) { return 0; } + let allowed: i64 = segmented_control_all_enabled_mask(model.segment_count); + if (model.enabled_mask <= 0 || (model.enabled_mask & (0 - 1 - allowed)) != 0 || + segmented_control_index_enabled(model, model.selected_index) == 0) { return 0; } + if (segmented_control_index_enabled(model, model.roving_index) == 0 || + (model.has_focus != 0 && model.has_focus != 1) || + (model.focus_visible != 0 && model.focus_visible != 1) || + (model.focus_visible != 0 && model.has_focus == 0)) { return 0; } + if (model.hovered_index != segmented_control_no_index() && + segmented_control_index_enabled(model, model.hovered_index) == 0) { return 0; } + if (model.pressed_index != segmented_control_no_index() && + segmented_control_index_enabled(model, model.pressed_index) == 0) { return 0; } + return 1; +} + +fn segmented_control_event(kind: SegmentedControlEventKind, + target_index: i32) SegmentedControlEvent { + return SegmentedControlEvent{ .kind = kind, .target_index = target_index }; +} + +fn segmented_control_event_uses_target(kind: SegmentedControlEventKind) i32 { + return switch (kind) { + .pointer_enter => 1, .pointer_leave => 1, .pointer_down => 1, + .pointer_up_inside => 1, .focus_target => 1, .focus_lost => 1, + .select_target => 1, .disable_target => 1, .enable_target => 1, + .pointer_cancel => 0, .key_previous => 0, .key_next => 0, + .key_home => 0, .key_end => 0, .activate_focused => 0, + }; +} + +fn segmented_control_next_enabled(model: SegmentedControlModel, + start: i32, direction: i32) i32 { + let current: i32 = start; + if (current < 0 || current >= model.segment_count) { + current = model.selected_index; + } + let attempt: i32 = 0; + while (attempt < model.segment_count) { + current = current + direction; + if (current < 0) { current = model.segment_count - 1; } + if (current >= model.segment_count) { current = 0; } + if (segmented_control_index_enabled(model, current) != 0) { + return current; + } + attempt = attempt + 1; + } + return segmented_control_no_index(); +} + +fn segmented_control_edge_enabled(model: SegmentedControlModel, + from_end: i32) i32 { + let index: i32 = 0; + let direction: i32 = 1; + if (from_end != 0) { + index = model.segment_count - 1; + direction = 0 - 1; + } + let attempt: i32 = 0; + while (attempt < model.segment_count) { + if (segmented_control_index_enabled(model, index) != 0) { return index; } + index = index + direction; + attempt = attempt + 1; + } + return segmented_control_no_index(); +} + +fn segmented_control_transition(model: SegmentedControlModel) + SegmentedControlTransition { + return SegmentedControlTransition{ + .model = model, + .previous_selected_index = model.selected_index, + .selected_index = model.selected_index, + .selection_changed = 0, + .roving_changed = 0, + .focus_changed = 0, + .activated = 0, + .rejected = 0, + }; +} + +fn segmented_control_select(transition: *SegmentedControlTransition, + index: i32, activate: i32) void { + if (segmented_control_index_enabled(transition.*.model, index) == 0) { + transition.*.rejected = 1; + return; + } + if (transition.*.model.selected_index != index) { + transition.*.model.selected_index = index; + transition.*.selection_changed = 1; + } + if (transition.*.model.roving_index != index) { + transition.*.model.roving_index = index; + transition.*.roving_changed = 1; + } + if (activate != 0) { transition.*.activated = 1; } +} + +fn segmented_control_set_focus( + transition: *SegmentedControlTransition, + has_focus: i32, focus_visible: i32) void { + if (transition.*.model.has_focus != has_focus || + transition.*.model.focus_visible != focus_visible) { + transition.*.focus_changed = 1; + } + transition.*.model.has_focus = has_focus; + transition.*.model.focus_visible = focus_visible; +} + +fn segmented_control_reduce(current: SegmentedControlModel, + event: SegmentedControlEvent) SegmentedControlTransition { + let result: SegmentedControlTransition = segmented_control_transition(current); + if (segmented_control_model_valid(current) == 0) { + result.rejected = 1; + return result; + } + if (segmented_control_event_uses_target(event.kind) != 0 && + segmented_control_index_in_range(current, event.target_index) == 0) { + result.rejected = 1; + return result; + } + + switch (event.kind) { + .pointer_enter => { + if (segmented_control_index_enabled(current, event.target_index) != 0) { + result.model.hovered_index = event.target_index; + } else { result.rejected = 1; } + } + .pointer_leave => { + if (result.model.hovered_index == event.target_index) { + result.model.hovered_index = segmented_control_no_index(); + } + if (result.model.pressed_index == event.target_index) { + result.model.pressed_index = segmented_control_no_index(); + } + } + .pointer_down => { + if (segmented_control_index_enabled(current, event.target_index) != 0) { + result.model.pressed_index = event.target_index; + if (result.model.roving_index != event.target_index) { + result.model.roving_index = event.target_index; + result.roving_changed = 1; + } + segmented_control_set_focus(&result, 1, 0); + } else { result.rejected = 1; } + } + .pointer_up_inside => { + if (current.pressed_index == event.target_index && + segmented_control_index_enabled(current, event.target_index) != 0) { + segmented_control_select(&result, event.target_index, 1); + } else { result.rejected = 1; } + result.model.pressed_index = segmented_control_no_index(); + } + .pointer_cancel => { + result.model.pressed_index = segmented_control_no_index(); + } + .focus_target => { + if (segmented_control_index_enabled(current, event.target_index) != 0) { + if (result.model.roving_index != event.target_index) { + result.model.roving_index = event.target_index; + result.roving_changed = 1; + } + segmented_control_set_focus(&result, 1, 1); + } else { result.rejected = 1; } + } + .focus_lost => { + if (result.model.roving_index == event.target_index) { + segmented_control_set_focus(&result, 0, 0); + } + result.model.pressed_index = segmented_control_no_index(); + } + .key_previous => { + let origin: i32 = current.roving_index; + if (current.has_focus == 0) { origin = current.selected_index; } + let previous: i32 = segmented_control_next_enabled(current, + origin, 0 - 1); + segmented_control_select(&result, previous, 1); + segmented_control_set_focus(&result, 1, 1); + } + .key_next => { + let origin: i32 = current.roving_index; + if (current.has_focus == 0) { origin = current.selected_index; } + let next: i32 = segmented_control_next_enabled(current, + origin, 1); + segmented_control_select(&result, next, 1); + segmented_control_set_focus(&result, 1, 1); + } + .key_home => { + segmented_control_select(&result, + segmented_control_edge_enabled(current, 0), 1); + segmented_control_set_focus(&result, 1, 1); + } + .key_end => { + segmented_control_select(&result, + segmented_control_edge_enabled(current, 1), 1); + segmented_control_set_focus(&result, 1, 1); + } + .activate_focused => { + let target: i32 = current.roving_index; + if (current.has_focus == 0) { target = current.selected_index; } + segmented_control_select(&result, target, 1); + segmented_control_set_focus(&result, 1, 1); + } + .select_target => { + segmented_control_select(&result, event.target_index, 1); + } + .disable_target => { + if (segmented_control_index_enabled(current, event.target_index) == 0 || + current.enabled_mask == segmented_control_bit(event.target_index)) { + result.rejected = 1; + } else { + result.model.enabled_mask = result.model.enabled_mask & + (0 - 1 - segmented_control_bit(event.target_index)); + if (result.model.hovered_index == event.target_index) { + result.model.hovered_index = segmented_control_no_index(); + } + if (result.model.pressed_index == event.target_index) { + result.model.pressed_index = segmented_control_no_index(); + } + if (result.model.selected_index == event.target_index) { + let replacement: i32 = segmented_control_next_enabled( + result.model, event.target_index, 1); + segmented_control_select(&result, replacement, 0); + } else if (result.model.roving_index == event.target_index) { + result.model.roving_index = result.model.selected_index; + result.roving_changed = 1; + } + } + } + .enable_target => { + if (segmented_control_index_enabled(current, event.target_index) != 0) { + result.rejected = 1; + } else { + result.model.enabled_mask = result.model.enabled_mask | + segmented_control_bit(event.target_index); + } + } + } + result.selected_index = result.model.selected_index; + return result; +} + +fn segmented_control_build_limits() SegmentedControlBuildLimits { + return SegmentedControlBuildLimits{ + .display_operations = 128, + .hit_nodes = segmented_control_maximum_segments() + 1, + .semantic_nodes = segmented_control_maximum_segments() + 1, + }; +} + +// Convenience constructor for static/literal group text. Dynamic borrowed text +// stays in a caller-scoped SegmentedControlSpec literal until Zag has an +// explicit returned-borrow relation distinct from call-duration @borrows. +fn segmented_control_spec(id: NodeKey, parent: NodeKey, bounds: Rect, + name: []u8, model: SegmentedControlModel) SegmentedControlSpec { + return SegmentedControlSpec{ + .id = id, + .parent = parent, + .bounds = bounds, + .name = name, + .description = "Single-selection segmented control", + .focus_order_base = 1, + .z_order = 0, + .density = FlexDensity.standard, + .purpose = SegmentedControlPurpose.view_switcher, + .model = model, + .limits = segmented_control_build_limits(), + }; +} + +fn segmented_control_density_valid(density: FlexDensity) i32 { + let value: i64 = density as i64; + return (value >= FlexDensity.compact as i64 && + value <= FlexDensity.touch as i64) as i32; +} + +fn segmented_control_purpose_valid(purpose: SegmentedControlPurpose) i32 { + let value: i64 = purpose as i64; + return (value >= SegmentedControlPurpose.view_switcher as i64 && + value <= SegmentedControlPurpose.value_picker as i64) as i32; +} + +fn segmented_control_option_role( + spec: SegmentedControlSpec) SemanticRole { + if (spec.purpose == SegmentedControlPurpose.value_picker) { + return SemanticRole.radio; + } + return SemanticRole.tab; +} + +fn segmented_control_group_role( + spec: SegmentedControlSpec) SemanticRole { + if (spec.purpose == SegmentedControlPurpose.value_picker) { + return SemanticRole.radio_group; + } + return SemanticRole.tab_list; +} + +fn segmented_control_padding(density: FlexDensity) i64 { + return flex_spacing(FlexSpacingToken.micro, density); +} + +fn segmented_control_gap(density: FlexDensity) i64 { + return flex_spacing(FlexSpacingToken.micro, density); +} + +fn segmented_control_minimum_target() i64 { return 44 * unit_scale(); } + +fn segmented_control_minimum_width(model: SegmentedControlModel, + density: FlexDensity) i64 { + return segmented_control_padding(density) * 2 + + segmented_control_minimum_target() * model.segment_count as i64 + + segmented_control_gap(density) * (model.segment_count - 1) as i64; +} + +fn segmented_control_minimum_height(density: FlexDensity) i64 { + return segmented_control_minimum_target() + + segmented_control_padding(density) * 2; +} + +fn segmented_control_segment_bounds(spec: SegmentedControlSpec, + index: i32) Rect { + if (segmented_control_index_in_range(spec.model, index) == 0) { + return rect(0, 0, 0, 0); + } + let padding: i64 = segmented_control_padding(spec.density); + let gap: i64 = segmented_control_gap(spec.density); + let available: i64 = spec.bounds.width - padding * 2 - + gap * (spec.model.segment_count - 1) as i64; + let base: i64 = available / spec.model.segment_count as i64; + let remainder: i64 = available % spec.model.segment_count as i64; + let extra_before: i64 = index as i64; + if (extra_before > remainder) { extra_before = remainder; } + let width: i64 = base; + if (index as i64 < remainder) { width = width + 1; } + return rect(spec.bounds.x + padding + + index as i64 * (base + gap) + extra_before, + spec.bounds.y + padding, width, spec.bounds.height - padding * 2); +} + +fn segmented_control_content_bounds(spec: SegmentedControlSpec, + index: i32) Rect { + let bounds: Rect = segmented_control_segment_bounds(spec, index); + if (bounds.width <= 0 || bounds.height <= 0) { return bounds; } + let horizontal: i64 = flex_spacing(FlexSpacingToken.large, spec.density); + let vertical: i64 = flex_spacing(FlexSpacingToken.small, spec.density); + return rect(bounds.x + horizontal, bounds.y + vertical, + bounds.width - horizontal * 2, bounds.height - vertical * 2); +} + +fn segmented_control_style(spec: SegmentedControlSpec, + index: i32) SegmentedControlResolvedStyle { + let selected: i32 = (spec.model.selected_index == index) as i32; + let hovered: i32 = (spec.model.hovered_index == index) as i32; + let has_focus: i32 = (spec.model.has_focus != 0 && + spec.model.roving_index == index) as i32; + let focus_visible: i32 = (has_focus != 0 && + spec.model.focus_visible != 0) as i32; + let focused: i32 = focus_visible; + let pressed: i32 = (spec.model.pressed_index == index) as i32; + let disabled: i32 = (segmented_control_index_enabled(spec.model, index) == 0) as i32; + let style: SegmentedControlResolvedStyle = SegmentedControlResolvedStyle{ + .fill_token = SemanticColorToken.surface_base, + .border_token = SemanticColorToken.border_subtle, + .label_color_token = SemanticColorToken.text_primary, + .focus_token = SemanticColorToken.focus, + .marker_token = SemanticColorToken.accent, + .fill_alpha = 9000, + .border_alpha = 7000, + .radius = semantic_radius(SemanticRadiusToken.control) - + segmented_control_padding(spec.density), + .elevation = SemanticElevationToken.base, + .selected = selected, + .hovered = hovered, + .focused = focused, + .has_focus = has_focus, + .focus_visible = focus_visible, + .pressed = pressed, + .disabled = disabled, + }; + if (hovered != 0) { + style.fill_token = SemanticColorToken.surface_interactive; + style.fill_alpha = 40000; + style.border_alpha = 15000; + } + if (selected != 0) { + style.fill_token = SemanticColorToken.surface_selected; + style.border_token = SemanticColorToken.border_emphasis; + style.fill_alpha = 52000; + style.border_alpha = 22000; + style.elevation = SemanticElevationToken.panel; + } + if (pressed != 0) { + style.fill_token = SemanticColorToken.accent_emphasis; + style.border_token = SemanticColorToken.accent; + style.fill_alpha = 50000; + style.border_alpha = 30000; + style.elevation = SemanticElevationToken.base; + } + if (focused != 0 && disabled == 0) { + style.border_token = style.focus_token; + style.border_alpha = 65535; + } + if (disabled != 0) { + style.fill_token = SemanticColorToken.surface_inset; + style.border_token = SemanticColorToken.border_subtle; + style.label_color_token = SemanticColorToken.text_disabled; + style.fill_alpha = 6000; + style.border_alpha = 4000; + style.elevation = SemanticElevationToken.base; + } + return style; +} + +fn segmented_control_options_valid(spec: SegmentedControlSpec, + options: *ArrayList[SegmentedControlOption]) i32 { + if (options.*.len != spec.model.segment_count) { return 0; } + let index: i32 = 0; + while (index < options.*.len) { + let option: SegmentedControlOption = options.*.data[index]; + if (node_key_equal(option.id, spec.id) != 0 || + node_key_equal(option.id, hit_root_key()) != 0 || + node_key_equal(option.id, semantic_none_key()) != 0 || + option.label.len <= 0 || + option.label.len > segmented_control_label_byte_limit() || + option.description.len > segmented_control_description_byte_limit() || + semantic_text_input_valid(option.label) == 0 || + semantic_text_input_valid(option.description) == 0) { return 0; } + let previous: i32 = 0; + while (previous < index) { + if (node_key_equal(options.*.data[previous].id, option.id) != 0) { + return 0; + } + previous = previous + 1; + } + index = index + 1; + } + return 1; +} + +fn segmented_control_spec_valid(spec: SegmentedControlSpec, + options: *ArrayList[SegmentedControlOption]) i32 { + if (node_key_equal(spec.id, hit_root_key()) != 0 || + node_key_equal(spec.id, semantic_none_key()) != 0 || + spec.name.len <= 0 || + spec.name.len > segmented_control_label_byte_limit() || + spec.description.len > segmented_control_description_byte_limit() || + semantic_text_input_valid(spec.name) == 0 || + semantic_text_input_valid(spec.description) == 0 || + segmented_control_model_valid(spec.model) == 0 || + segmented_control_density_valid(spec.density) == 0 || + segmented_control_purpose_valid(spec.purpose) == 0 || + hit_geometry_valid(spec.bounds) == 0 || + spec.bounds.width < segmented_control_minimum_width(spec.model, spec.density) || + spec.bounds.height < segmented_control_minimum_height(spec.density) || + spec.focus_order_base <= 0 || + spec.focus_order_base > 1000000000 || + spec.z_order < 0 || + spec.z_order > 1000000000 - spec.model.segment_count as i64 || + spec.limits.display_operations < 0 || + spec.limits.hit_nodes < 0 || spec.limits.semantic_nodes < 0 || + segmented_control_options_valid(spec, options) == 0) { return 0; } + return 1; +} + +fn segmented_control_empty_artifact(spec: SegmentedControlSpec) + SegmentedControlArtifact { + return SegmentedControlArtifact{ + .id = spec.id, + .bounds = spec.bounds, + .segment_count = spec.model.segment_count, + .selected_index = spec.model.selected_index, + .selected_id = semantic_none_key(), + .selected_bounds = rect(0, 0, 0, 0), + .roving_index = spec.model.roving_index, + .has_focus = spec.model.has_focus, + .focus_visible = spec.model.focus_visible, + .selected_marker_visible = 0, + .rail_fill_token = SemanticColorToken.surface_inset, + .rail_border_token = SemanticColorToken.border_subtle, + .rail_radius_token = SemanticRadiusToken.control, + .rail_elevation_token = SemanticElevationToken.base, + .label_type_token = SemanticTypeToken.label, + .purpose = spec.purpose, + .group_role = segmented_control_group_role(spec), + .display_op_start = segmented_control_no_index(), + .display_op_end = segmented_control_no_index(), + .group_hit_node_index = segmented_control_no_index(), + .group_semantics_node_index = segmented_control_no_index(), + .evidence_hash = 0, + }; +} + +fn segmented_control_build_result(spec: SegmentedControlSpec, + error: SegmentedControlBuildError) SegmentedControlBuildResult { + return SegmentedControlBuildResult{ + .artifact = segmented_control_empty_artifact(spec), + .error = error, + .capacity_error = SegmentedControlCapacityError.none, + .display_error = DisplayError.none, + .hit_error = HitTreeError.none, + .semantics_error = SemanticsError.none, + }; +} + +fn segmented_control_checkpoint(display: *DisplayList, + hits: *HitTree, + semantics: *SemanticsTree) SegmentedControlCheckpoint { + return SegmentedControlCheckpoint{ + .display_length = display.*.ops.len, + .display_revision = display.*.revision, + .display_hash = display.*.content_hash, + .display_last_error = display.*.last_error, + .display_error_index = display.*.error_index, + .hit_length = hits.*.nodes.len, + .hit_revision = hits.*.revision, + .hit_last_error = hits.*.last_error, + .hit_error_node = hits.*.error_node, + .semantics = semantics_tree_checkpoint(semantics), + }; +} + +fn segmented_control_rollback(display: *DisplayList, + hits: *HitTree, + semantics: *SemanticsTree, + checkpoint: SegmentedControlCheckpoint) void { + display.*.ops.len = checkpoint.display_length; + display.*.revision = checkpoint.display_revision; + display.*.content_hash = checkpoint.display_hash; + display.*.last_error = checkpoint.display_last_error; + display.*.error_index = checkpoint.display_error_index; + hits.*.nodes.len = checkpoint.hit_length; + hits.*.revision = checkpoint.hit_revision; + hits.*.last_error = checkpoint.hit_last_error; + hits.*.error_node = checkpoint.hit_error_node; + _ = semantics_tree_rollback(semantics, checkpoint.semantics); +} + +fn segmented_control_budget(limits: SegmentedControlBuildLimits) + SegmentedControlBuildBudget { + return SegmentedControlBuildBudget{ + .limits = limits, + .display_operations = 0, + .hit_nodes = 0, + .semantic_nodes = 0, + }; +} + +fn segmented_control_take_display( + budget: *SegmentedControlBuildBudget) i32 { + if (budget.*.display_operations >= budget.*.limits.display_operations) { + return 0; + } + budget.*.display_operations = budget.*.display_operations + 1; + return 1; +} + +fn segmented_control_take_hit( + budget: *SegmentedControlBuildBudget) i32 { + if (budget.*.hit_nodes >= budget.*.limits.hit_nodes) { return 0; } + budget.*.hit_nodes = budget.*.hit_nodes + 1; + return 1; +} + +fn segmented_control_take_semantic( + budget: *SegmentedControlBuildBudget) i32 { + if (budget.*.semantic_nodes >= budget.*.limits.semantic_nodes) { return 0; } + budget.*.semantic_nodes = budget.*.semantic_nodes + 1; + return 1; +} + +fn segmented_control_push_round(spec: SegmentedControlSpec, + display: *DisplayList, + budget: *SegmentedControlBuildBudget, + owner: NodeKey, bounds: Rect, radius: i64, + paint: Paint) SegmentedControlBuildResult { + let result: SegmentedControlBuildResult = segmented_control_build_result( + spec, SegmentedControlBuildError.none); + if (segmented_control_take_display(budget) == 0) { + result.error = SegmentedControlBuildError.capacity_error; + result.capacity_error = SegmentedControlCapacityError.display_operations; + return result; + } + let op: DisplayOp = display_op(DisplayOpKind.fill_rounded_rect, owner); + op.bounds = bounds; + op.parameter = radius; + op.paint = paint; + let error: DisplayError = display_list_push(display, op); + if (error != DisplayError.none) { + result.error = SegmentedControlBuildError.display_error; + result.display_error = error; + } + return result; +} + +fn segmented_control_push_rect(spec: SegmentedControlSpec, + display: *DisplayList, + budget: *SegmentedControlBuildBudget, + owner: NodeKey, bounds: Rect, + paint: Paint) SegmentedControlBuildResult { + let result: SegmentedControlBuildResult = segmented_control_build_result( + spec, SegmentedControlBuildError.none); + if (segmented_control_take_display(budget) == 0) { + result.error = SegmentedControlBuildError.capacity_error; + result.capacity_error = SegmentedControlCapacityError.display_operations; + return result; + } + let op: DisplayOp = display_op(DisplayOpKind.fill_rect, owner); + op.bounds = bounds; + op.paint = paint; + let error: DisplayError = display_list_push(display, op); + if (error != DisplayError.none) { + result.error = SegmentedControlBuildError.display_error; + result.display_error = error; + } + return result; +} + +fn segmented_control_mix(hash: i64, value: i64) i64 { + return (hash * 1099511628211) ^ value; +} + +fn segmented_control_text_hash(hash: i64, text: []u8) i64 { + let result: i64 = segmented_control_mix(hash, text.len as i64); + let index: i32 = 0; + while (index < text.len) { + result = segmented_control_mix(result, text[index] as i64); + index = index + 1; + } + return result; +} + +fn segmented_control_evidence_hash(spec: SegmentedControlSpec, + options: *ArrayList[SegmentedControlOption], + artifact: SegmentedControlArtifact) i64 { + let hash: i64 = segmented_control_mix(17, artifact.id.value); + hash = segmented_control_mix(hash, artifact.id.generation); + hash = segmented_control_mix(hash, artifact.bounds.x); + hash = segmented_control_mix(hash, artifact.bounds.y); + hash = segmented_control_mix(hash, artifact.bounds.width); + hash = segmented_control_mix(hash, artifact.bounds.height); + hash = segmented_control_mix(hash, spec.parent.value); + hash = segmented_control_mix(hash, spec.parent.generation); + hash = segmented_control_mix(hash, artifact.segment_count as i64); + hash = segmented_control_mix(hash, spec.model.selected_index as i64); + hash = segmented_control_mix(hash, spec.model.roving_index as i64); + hash = segmented_control_mix(hash, spec.model.has_focus as i64); + hash = segmented_control_mix(hash, spec.model.focus_visible as i64); + hash = segmented_control_mix(hash, spec.model.hovered_index as i64); + hash = segmented_control_mix(hash, spec.model.pressed_index as i64); + hash = segmented_control_mix(hash, spec.model.enabled_mask); + hash = segmented_control_mix(hash, spec.focus_order_base); + hash = segmented_control_mix(hash, spec.z_order); + hash = segmented_control_mix(hash, spec.density as i64); + hash = segmented_control_mix(hash, spec.purpose as i64); + hash = segmented_control_mix(hash, artifact.rail_fill_token as i64); + hash = segmented_control_mix(hash, artifact.rail_border_token as i64); + hash = segmented_control_mix(hash, artifact.rail_radius_token as i64); + hash = segmented_control_mix(hash, artifact.rail_elevation_token as i64); + hash = segmented_control_mix(hash, artifact.label_type_token as i64); + hash = segmented_control_mix(hash, artifact.group_role as i64); + hash = segmented_control_text_hash(hash, spec.name); + hash = segmented_control_text_hash(hash, spec.description); + let index: i32 = 0; + while (index < options.*.len) { + let option: SegmentedControlOption = options.*.data[index]; + let bounds: Rect = segmented_control_segment_bounds(spec, index); + let content: Rect = segmented_control_content_bounds(spec, index); + let style: SegmentedControlResolvedStyle = segmented_control_style(spec, index); + hash = segmented_control_mix(hash, option.id.value); + hash = segmented_control_mix(hash, option.id.generation); + hash = segmented_control_mix(hash, bounds.x); + hash = segmented_control_mix(hash, bounds.y); + hash = segmented_control_mix(hash, bounds.width); + hash = segmented_control_mix(hash, bounds.height); + hash = segmented_control_mix(hash, content.x); + hash = segmented_control_mix(hash, content.width); + hash = segmented_control_mix(hash, style.fill_token as i64); + hash = segmented_control_mix(hash, style.border_token as i64); + hash = segmented_control_mix(hash, style.label_color_token as i64); + hash = segmented_control_mix(hash, style.fill_alpha); + hash = segmented_control_mix(hash, style.border_alpha); + hash = segmented_control_mix(hash, style.radius); + hash = segmented_control_mix(hash, style.elevation as i64); + hash = segmented_control_mix(hash, style.selected as i64); + hash = segmented_control_mix(hash, style.hovered as i64); + hash = segmented_control_mix(hash, style.focused as i64); + hash = segmented_control_mix(hash, style.has_focus as i64); + hash = segmented_control_mix(hash, style.focus_visible as i64); + hash = segmented_control_mix(hash, style.pressed as i64); + hash = segmented_control_mix(hash, style.disabled as i64); + hash = segmented_control_text_hash(hash, option.label); + hash = segmented_control_text_hash(hash, option.description); + index = index + 1; + } + return hash; +} + +fn segmented_control_capacity_failure(spec: SegmentedControlSpec, + kind: SegmentedControlCapacityError) SegmentedControlBuildResult { + let result: SegmentedControlBuildResult = segmented_control_build_result( + spec, SegmentedControlBuildError.capacity_error); + result.capacity_error = kind; + return result; +} + +fn segmented_control_emit(spec: SegmentedControlSpec, + options: *ArrayList[SegmentedControlOption], + display: *DisplayList, hits: *HitTree, + semantics: *SemanticsTree) SegmentedControlBuildResult { + let result: SegmentedControlBuildResult = segmented_control_build_result( + spec, SegmentedControlBuildError.none); + if (segmented_control_spec_valid(spec, options) == 0) { + result.error = SegmentedControlBuildError.invalid_spec; + return result; + } + if (display.*.sealed != 0) { + result.error = SegmentedControlBuildError.display_error; + result.display_error = DisplayError.sealed; + return result; + } + let checkpoint: SegmentedControlCheckpoint = segmented_control_checkpoint( + display, hits, semantics); + let budget: SegmentedControlBuildBudget = segmented_control_budget(spec.limits); + let radius: i64 = semantic_radius(SemanticRadiusToken.control); + let edge: i64 = unit_scale(); + let operation: SegmentedControlBuildResult = segmented_control_push_round( + spec, display, &budget, spec.id, spec.bounds, radius, + semantic_color_with_alpha(SemanticColorToken.border_subtle, 18000)); + if (operation.error == SegmentedControlBuildError.none) { + operation = segmented_control_push_round(spec, display, &budget, spec.id, + rect(spec.bounds.x + edge, spec.bounds.y + edge, + spec.bounds.width - edge * 2, spec.bounds.height - edge * 2), + radius - edge, + semantic_color_with_alpha(SemanticColorToken.surface_inset, 62000)); + } + let divider: i32 = 1; + while (operation.error == SegmentedControlBuildError.none && + divider < spec.model.segment_count) { + let before: Rect = segmented_control_segment_bounds(spec, divider - 1); + let after: Rect = segmented_control_segment_bounds(spec, divider); + let divider_x: i64 = before.x + before.width + + (after.x - before.x - before.width - unit_scale()) / 2; + operation = segmented_control_push_rect(spec, display, &budget, spec.id, + rect(divider_x, spec.bounds.y + + flex_spacing(FlexSpacingToken.small, spec.density), + unit_scale(), spec.bounds.height - + flex_spacing(FlexSpacingToken.small, spec.density) * 2), + semantic_color_with_alpha(SemanticColorToken.border_subtle, 16000)); + divider = divider + 1; + } + let index: i32 = 0; + while (operation.error == SegmentedControlBuildError.none && + index < spec.model.segment_count) { + let bounds: Rect = segmented_control_segment_bounds(spec, index); + let style: SegmentedControlResolvedStyle = segmented_control_style(spec, index); + let elevation: ResolvedElevationToken = semantic_elevation(style.elevation); + if (elevation.ambient_shadow_alpha > 0) { + operation = segmented_control_push_round(spec, display, &budget, + options.*.data[index].id, + rect(bounds.x - elevation.ambient_shadow_blur, + bounds.y + elevation.ambient_shadow_y - elevation.ambient_shadow_blur, + bounds.width + elevation.ambient_shadow_blur * 2, + bounds.height + elevation.ambient_shadow_blur * 2), + style.radius + elevation.ambient_shadow_blur, + semantic_color_with_alpha(SemanticColorToken.shadow, + elevation.ambient_shadow_alpha)); + } + if (operation.error == SegmentedControlBuildError.none) { + operation = segmented_control_push_round(spec, display, &budget, + options.*.data[index].id, bounds, style.radius, + semantic_color_with_alpha(style.border_token, style.border_alpha)); + } + if (operation.error == SegmentedControlBuildError.none) { + let inset: i64 = unit_scale(); + if (style.pressed != 0 || style.focused != 0) { + inset = 2 * unit_scale(); + } + operation = segmented_control_push_round(spec, display, &budget, + options.*.data[index].id, + rect(bounds.x + inset, bounds.y + inset, + bounds.width - inset * 2, bounds.height - inset * 2), + style.radius - inset, + semantic_color_with_alpha(style.fill_token, style.fill_alpha)); + } + if (operation.error == SegmentedControlBuildError.none && + style.selected != 0) { + let marker_height: i64 = 3 * unit_scale(); + let marker_inset: i64 = flex_spacing(FlexSpacingToken.large, + spec.density); + operation = segmented_control_push_round(spec, display, &budget, + options.*.data[index].id, + rect(bounds.x + marker_inset, + bounds.y + bounds.height - marker_height - + flex_spacing(FlexSpacingToken.tiny, spec.density), + bounds.width - marker_inset * 2, marker_height), + marker_height / 2, + semantic_color_with_alpha(style.marker_token, 65535)); + } + index = index + 1; + } + if (operation.error != SegmentedControlBuildError.none) { + segmented_control_rollback(display, hits, semantics, checkpoint); + return operation; + } + + if (segmented_control_take_hit(&budget) == 0) { + segmented_control_rollback(display, hits, semantics, checkpoint); + return segmented_control_capacity_failure(spec, + SegmentedControlCapacityError.hit_nodes); + } + let group_hit: HitNode = hit_node(spec.id, spec.parent, spec.bounds); + group_hit.clip = spec.bounds; + group_hit.has_clip = 1; + group_hit.z_order = spec.z_order; + group_hit.enabled = 0; + group_hit.focusable = 0; + let hit_error: HitTreeError = hit_tree_add(hits, group_hit); + if (hit_error != HitTreeError.none) { + segmented_control_rollback(display, hits, semantics, checkpoint); + result.error = SegmentedControlBuildError.hit_error; + result.hit_error = hit_error; + return result; + } + index = 0; + while (index < spec.model.segment_count) { + if (segmented_control_take_hit(&budget) == 0) { + segmented_control_rollback(display, hits, semantics, checkpoint); + return segmented_control_capacity_failure(spec, + SegmentedControlCapacityError.hit_nodes); + } + let enabled: i32 = segmented_control_index_enabled(spec.model, index); + let child_hit: HitNode = hit_node(options.*.data[index].id, + spec.id, segmented_control_segment_bounds(spec, index)); + child_hit.clip = spec.bounds; + child_hit.has_clip = 1; + child_hit.z_order = spec.z_order + 1 + index as i64; + child_hit.enabled = enabled; + child_hit.focusable = (enabled != 0 && + spec.model.roving_index == index) as i32; + let child_hit_error: HitTreeError = hit_tree_add(hits, child_hit); + if (child_hit_error != HitTreeError.none) { + segmented_control_rollback(display, hits, semantics, checkpoint); + result.error = SegmentedControlBuildError.hit_error; + result.hit_error = child_hit_error; + return result; + } + index = index + 1; + } + + if (segmented_control_take_semantic(&budget) == 0) { + segmented_control_rollback(display, hits, semantics, checkpoint); + return segmented_control_capacity_failure(spec, + SegmentedControlCapacityError.semantic_nodes); + } + let group_semantic: SemanticsSpec = semantics_spec(spec.id, spec.parent, + segmented_control_group_role(spec), spec.name); + group_semantic.description = spec.description; + group_semantic.value = "Single selection"; + group_semantic.has_bounds = 1; + group_semantic.bounds = spec.bounds; + let semantics_error: SemanticsError = semantics_add(semantics, group_semantic); + if (semantics_error != SemanticsError.none) { + segmented_control_rollback(display, hits, semantics, checkpoint); + result.error = SegmentedControlBuildError.semantics_error; + result.semantics_error = semantics_error; + return result; + } + index = 0; + while (index < spec.model.segment_count) { + if (segmented_control_take_semantic(&budget) == 0) { + segmented_control_rollback(display, hits, semantics, checkpoint); + return segmented_control_capacity_failure(spec, + SegmentedControlCapacityError.semantic_nodes); + } + let enabled: i32 = segmented_control_index_enabled(spec.model, index); + let selected: i32 = (spec.model.selected_index == index) as i32; + let option: SegmentedControlOption = options.*.data[index]; + let semantic: SemanticsSpec = semantics_spec(option.id, spec.id, + segmented_control_option_role(spec), option.label); + semantic.description = option.description; + semantic.value = "Not selected"; + if (selected != 0) { semantic.value = "Selected"; } + if (enabled == 0) { semantic.value = "Unavailable"; } + if (enabled != 0 && spec.model.roving_index == index) { + semantic.focus_order = spec.focus_order_base; + } + semantic.disabled = (enabled == 0) as i32; + semantic.selected = selected; + semantic.focused = (enabled != 0 && spec.model.has_focus != 0 && + spec.model.roving_index == index) as i32; + semantic.set_size = spec.model.segment_count as i64; + semantic.position_in_set = index as i64 + 1; + semantic.has_bounds = 1; + semantic.bounds = segmented_control_segment_bounds(spec, index); + if (enabled != 0) { + semantic.action_mask = semantic_actions_add( + semantic_actions_add( + semantic_actions(SemanticAction.activate), + SemanticAction.select), SemanticAction.focus); + } + let child_semantic_error: SemanticsError = semantics_add( + semantics, semantic); + if (child_semantic_error != SemanticsError.none) { + segmented_control_rollback(display, hits, semantics, checkpoint); + result.error = SegmentedControlBuildError.semantics_error; + result.semantics_error = child_semantic_error; + return result; + } + index = index + 1; + } + + result.artifact = SegmentedControlArtifact{ + .id = spec.id, + .bounds = spec.bounds, + .segment_count = spec.model.segment_count, + .selected_index = spec.model.selected_index, + .selected_id = options.*.data[spec.model.selected_index].id, + .selected_bounds = segmented_control_segment_bounds(spec, + spec.model.selected_index), + .roving_index = spec.model.roving_index, + .has_focus = spec.model.has_focus, + .focus_visible = spec.model.focus_visible, + .selected_marker_visible = 1, + .rail_fill_token = SemanticColorToken.surface_inset, + .rail_border_token = SemanticColorToken.border_subtle, + .rail_radius_token = SemanticRadiusToken.control, + .rail_elevation_token = SemanticElevationToken.base, + .label_type_token = SemanticTypeToken.label, + .purpose = spec.purpose, + .group_role = segmented_control_group_role(spec), + .display_op_start = checkpoint.display_length, + .display_op_end = display.*.ops.len, + .group_hit_node_index = checkpoint.hit_length, + .group_semantics_node_index = checkpoint.semantics.length, + .evidence_hash = 0, + }; + result.artifact.evidence_hash = segmented_control_evidence_hash(spec, + options, result.artifact); + return result; +} diff --git a/src/components/surface.zag b/src/components/surface.zag new file mode 100644 index 0000000..3b86835 --- /dev/null +++ b/src/components/surface.zag @@ -0,0 +1,963 @@ +@import("button.zag") + +// Surface is Zagkit's canonical retained container. One stable NodeKey owns +// its display operations, hit node, semantic node, and Talkback identity. +// Child content is emitted separately into the exact Flex-derived +// `content_bounds` returned by SurfaceArtifact. + +enum SurfaceTier { base, panel, raised, overlay } +enum SurfaceMode { group, actionable } + +enum SurfaceCanonicalState { + rest, + hover, + focus, + pressed, + selected, + disabled, + loading, + error, +} + +enum SurfaceBuildError { + none, + invalid_spec, + capacity_error, + display_error, + hit_error, + semantics_error, +} + +enum SurfaceCapacityError { + none, + display_operations, + hit_nodes, + semantic_nodes, +} + +struct SurfaceBuildLimits { + display_operations: i32, + hit_nodes: i32, + semantic_nodes: i32, +} + +struct SurfaceBuildBudget { + limits: SurfaceBuildLimits, + display_operations: i32, + hit_nodes: i32, + semantic_nodes: i32, +} + +struct SurfaceSpec { + id: NodeKey, + parent: NodeKey, + bounds: Rect, + name: []u8, + description: []u8, + focus_order: i64, + z_order: i64, + tier: SurfaceTier, + mode: SurfaceMode, + density: FlexDensity, + interaction: ButtonInteraction, + live_region: LiveRegion, + limits: SurfaceBuildLimits, +} + +struct SurfaceTransition { + interaction: ButtonInteraction, + activate: i32, + state_changed: i32, + rejected: i32, +} + +struct SurfaceResolvedStyle { + material_token: SemanticMaterialToken, + material_fill_token: SemanticColorToken, + fill_token: SemanticColorToken, + edge_token: SemanticColorToken, + border_token: SemanticColorToken, + content_color_token: SemanticColorToken, + focus_token: SemanticColorToken, + state_token: SemanticColorToken, + radius_token: SemanticRadiusToken, + material_elevation_token: SemanticElevationToken, + elevation_token: SemanticElevationToken, + content_spacing_token: FlexSpacingToken, + fill_alpha: i64, + border_alpha: i64, +} + +struct SurfaceArtifact { + id: NodeKey, + bounds: Rect, + chrome_bounds: Rect, + content_bounds: Rect, + tier: SurfaceTier, + mode: SurfaceMode, + interaction: ButtonInteraction, + style: SurfaceResolvedStyle, + resolved_radius: i64, + effectively_enabled: i32, + hover_treatment_visible: i32, + focus_ring_visible: i32, + pressed_treatment_visible: i32, + selected_marker_visible: i32, + disabled_treatment_visible: i32, + loading_treatment_visible: i32, + error_treatment_visible: i32, + semantics_role: SemanticRole, + display_op_start: i32, + display_op_end: i32, + hit_node_index: i32, + semantics_node_index: i32, + evidence_hash: i64, +} + +struct SurfaceBuildResult { + artifact: SurfaceArtifact, + error: SurfaceBuildError, + capacity_error: SurfaceCapacityError, + display_error: DisplayError, + hit_error: HitTreeError, + semantics_error: SemanticsError, +} + +struct SurfaceCheckpoint { + display_length: i32, + display_revision: i64, + display_hash: i64, + display_last_error: DisplayError, + display_error_index: i32, + hit_length: i32, + hit_revision: i64, + hit_last_error: HitTreeError, + hit_error_node: NodeKey, + semantics: SemanticsCheckpoint, +} + +struct SurfaceDrawResult { + error: SurfaceBuildError, + capacity_error: SurfaceCapacityError, + display_error: DisplayError, +} + +fn surface_name_byte_limit() i32 { return 4096; } +fn surface_description_byte_limit() i32 { return 16384; } + +fn surface_build_limits() SurfaceBuildLimits { + return SurfaceBuildLimits{ + .display_operations = 20, + .hit_nodes = 1, + .semantic_nodes = 1, + }; +} + +fn surface_interaction() ButtonInteraction { return button_interaction(); } + +fn surface_canonical_state(state: SurfaceCanonicalState) ButtonInteraction { + return switch (state) { + .rest => button_canonical_state(ButtonCanonicalState.rest), + .hover => button_canonical_state(ButtonCanonicalState.hover), + .focus => button_canonical_state(ButtonCanonicalState.focus), + .pressed => button_canonical_state(ButtonCanonicalState.pressed), + .selected => button_canonical_state(ButtonCanonicalState.selected), + .disabled => button_canonical_state(ButtonCanonicalState.disabled), + .loading => button_canonical_state(ButtonCanonicalState.loading), + .error => button_canonical_state(ButtonCanonicalState.error), + }; +} + +fn surface_mode_valid(mode: SurfaceMode) i32 { + let value: i64 = mode as i64; + return (value >= SurfaceMode.group as i64 && + value <= SurfaceMode.actionable as i64) as i32; +} + +fn surface_tier_valid(tier: SurfaceTier) i32 { + let value: i64 = tier as i64; + return (value >= SurfaceTier.base as i64 && + value <= SurfaceTier.overlay as i64) as i32; +} + +fn surface_density_valid(density: FlexDensity) i32 { + let value: i64 = density as i64; + return (value >= FlexDensity.compact as i64 && + value <= FlexDensity.touch as i64) as i32; +} + +fn surface_live_region_valid(region: LiveRegion) i32 { + let value: i64 = region as i64; + return (value >= LiveRegion.off as i64 && + value <= LiveRegion.assertive as i64) as i32; +} + +// Material tier mapping: each SurfaceTier selects the SemanticMaterialToken +// whose resolved elevation_token and fill_token match the tier's semantic +// role. +// base → solid: fill=surface_base, elevation=base, no tint gradient, +// no blur. The bottom-most opaque layer should not simulate +// translucency or cast shadows. +// panel → veil: fill=surface_panel, elevation=panel, tint gradient +// 0→12000, blur=20. A translucent layer above the base. +// raised → glass: fill=surface_raised, elevation=raised, tint gradient, +// blur=30. A more visibly elevated translucent layer. +// overlay → glass: same material as raised but the surface_tier_elevation +// mapping selects SemanticElevationToken.overlay, producing +// deeper shadows. No separate overlay material is needed +// because the elevation token already differentiates the two. +fn surface_material_token(tier: SurfaceTier) SemanticMaterialToken { + if (surface_tier_valid(tier) == 0) { + return SemanticMaterialToken.solid; + } + return switch (tier) { + .base => SemanticMaterialToken.solid, + .panel => SemanticMaterialToken.veil, + .raised => SemanticMaterialToken.glass, + .overlay => SemanticMaterialToken.glass, + }; +} + +fn surface_content_spacing_token(tier: SurfaceTier) FlexSpacingToken { + if (surface_tier_valid(tier) == 0) { return FlexSpacingToken.medium; } + return switch (tier) { + .base => FlexSpacingToken.medium, + .panel => FlexSpacingToken.large, + .raised => FlexSpacingToken.xlarge, + .overlay => FlexSpacingToken.xlarge, + }; +} + +fn surface_group_interaction_valid(value: ButtonInteraction) i32 { + return button_interaction_equal(value, surface_interaction()); +} + +fn surface_action_interaction_valid(value: ButtonInteraction) i32 { + if (button_interaction_valid(value) == 0 || + (value.loading != 0 && value.error != 0)) { return 0; } + if (value.enabled == 0 && (value.hovered != 0 || value.focused != 0 || + value.pressed != 0 || value.loading != 0)) { return 0; } + return 1; +} + +fn surface_reduce(mode: SurfaceMode, current: ButtonInteraction, + event: ButtonEvent) SurfaceTransition { + let result: SurfaceTransition = SurfaceTransition{ + .interaction = current, + .activate = 0, + .state_changed = 0, + .rejected = 0, + }; + if (surface_mode_valid(mode) == 0 || + surface_action_interaction_valid(current) == 0) { + result.rejected = 1; + return result; + } + if (mode == SurfaceMode.group) { + result.rejected = 1; + return result; + } + let transition: ButtonTransition = button_reduce(current, event); + result.interaction = transition.interaction; + result.activate = transition.activate; + result.state_changed = transition.state_changed; + return result; +} + +// `name` is retained by the returned spec. Zag's lifetime contract therefore +// keeps dynamic backing alive until the last use of the spec; Surface never +// copies merely to hide an ownership relation. +fn surface_spec(id: NodeKey, parent: NodeKey, bounds: Rect, + name: []u8, tier: SurfaceTier) SurfaceSpec { + return SurfaceSpec{ + .id = id, + .parent = parent, + .bounds = bounds, + .name = name, + .description = "Content group", + .focus_order = 0, + .z_order = 0, + .tier = tier, + .mode = SurfaceMode.group, + .density = FlexDensity.standard, + .interaction = surface_interaction(), + .live_region = LiveRegion.off, + .limits = surface_build_limits(), + }; +} + +fn surface_action_spec(id: NodeKey, parent: NodeKey, bounds: Rect, + name: []u8, tier: SurfaceTier) SurfaceSpec { + let spec: SurfaceSpec = surface_spec(id, parent, bounds, name, tier); + spec.description = "Actionable card"; + spec.focus_order = 1; + spec.mode = SurfaceMode.actionable; + return spec; +} + +fn surface_minimum_action_target() i64 { return 44 * unit_scale(); } + +fn surface_minimum_extent(spec: SurfaceSpec) i64 { + let padding: i64 = flex_spacing(surface_content_spacing_token(spec.tier), + spec.density); + let minimum: i64 = padding * 2 + 8 * unit_scale(); + if (spec.mode == SurfaceMode.actionable && + minimum < surface_minimum_action_target()) { + return surface_minimum_action_target(); + } + return minimum; +} + +fn surface_tier_elevation(tier: SurfaceTier) SemanticElevationToken { + return switch (tier) { + .base => SemanticElevationToken.base, + .panel => SemanticElevationToken.panel, + .raised => SemanticElevationToken.raised, + .overlay => SemanticElevationToken.overlay, + }; +} + +fn surface_style(spec: SurfaceSpec) SurfaceResolvedStyle { + let material_token: SemanticMaterialToken = surface_material_token(spec.tier); + let material: ResolvedMaterialToken = semantic_material(material_token); + let tier_elevation: SemanticElevationToken = surface_tier_elevation(spec.tier); + let elevation: ResolvedElevationToken = semantic_elevation( + tier_elevation); + let style: SurfaceResolvedStyle = SurfaceResolvedStyle{ + .material_token = material_token, + .material_fill_token = material.fill_token, + .fill_token = material.fill_token, + .edge_token = material.edge_token, + .border_token = SemanticColorToken.border_subtle, + .content_color_token = SemanticColorToken.text_primary, + .focus_token = SemanticColorToken.focus, + .state_token = material.edge_token, + .radius_token = material.radius_token, + .material_elevation_token = tier_elevation, + .elevation_token = tier_elevation, + .content_spacing_token = surface_content_spacing_token(spec.tier), + .fill_alpha = semantic_color_paint(material.fill_token).alpha, + .border_alpha = elevation.edge_highlight_alpha, + }; + if (spec.mode == SurfaceMode.actionable && spec.interaction.hovered != 0) { + style.fill_token = SemanticColorToken.surface_interactive; + style.border_token = SemanticColorToken.border_emphasis; + style.state_token = SemanticColorToken.accent; + style.fill_alpha = 60000; + style.border_alpha = 26000; + } + if (spec.mode == SurfaceMode.actionable && spec.interaction.selected != 0) { + style.fill_token = SemanticColorToken.surface_selected; + style.border_token = SemanticColorToken.border_emphasis; + style.state_token = SemanticColorToken.accent; + style.fill_alpha = 61000; + style.border_alpha = 36000; + } + if (spec.mode == SurfaceMode.actionable && spec.interaction.pressed != 0) { + style.fill_token = SemanticColorToken.accent_emphasis; + style.border_token = SemanticColorToken.accent; + style.state_token = SemanticColorToken.accent; + style.fill_alpha = 62000; + style.border_alpha = 42000; + style.elevation_token = SemanticElevationToken.base; + } + if (spec.mode == SurfaceMode.actionable && spec.interaction.loading != 0) { + style.fill_token = SemanticColorToken.surface_inset; + style.content_color_token = SemanticColorToken.text_secondary; + style.state_token = SemanticColorToken.accent; + style.fill_alpha = 54000; + style.elevation_token = SemanticElevationToken.base; + } + if (spec.mode == SurfaceMode.actionable && spec.interaction.error != 0) { + style.border_token = SemanticColorToken.status_error; + style.state_token = SemanticColorToken.status_error; + style.border_alpha = 65535; + } + if (spec.mode == SurfaceMode.actionable && spec.interaction.enabled == 0) { + style.fill_token = SemanticColorToken.surface_inset; + style.border_token = SemanticColorToken.border_subtle; + style.content_color_token = SemanticColorToken.text_disabled; + style.state_token = SemanticColorToken.text_disabled; + style.fill_alpha = 36000; + style.border_alpha = 9000; + style.elevation_token = SemanticElevationToken.base; + } + return style; +} + +fn surface_chrome_bounds(spec: SurfaceSpec) Rect { + if (spec.mode == SurfaceMode.actionable && spec.interaction.pressed != 0) { + let inset: i64 = 2 * unit_scale(); + return rect(spec.bounds.x + inset, spec.bounds.y + inset, + spec.bounds.width - inset * 2, + spec.bounds.height - inset * 2); + } + return spec.bounds; +} + +fn surface_resolved_radius(token: SemanticRadiusToken, bounds: Rect) i64 { + let radius: i64 = semantic_radius(token); + let shortest: i64 = bounds.width; + if (bounds.height < shortest) { shortest = bounds.height; } + let maximum: i64 = shortest / 2; + if (radius > maximum) { radius = maximum; } + if (radius < unit_scale()) { radius = unit_scale(); } + return radius; +} + +fn surface_content_bounds(spec: SurfaceSpec) Rect { + let spacing: FlexSpacingToken = surface_content_spacing_token(spec.tier); + let horizontal: i64 = flex_spacing(spacing, spec.density); + let vertical: i64 = flex_spacing(spacing, spec.density); + let trailing: i64 = horizontal; + if (spec.mode == SurfaceMode.actionable && + (spec.interaction.loading != 0 || spec.interaction.error != 0 || + spec.interaction.enabled == 0)) { + trailing = horizontal + flex_spacing(FlexSpacingToken.xlarge, + spec.density); + } + if (horizontal + trailing >= spec.bounds.width) { + trailing = spec.bounds.width - horizontal - unit_scale(); + } + if (trailing < 0) { trailing = 0; } + return rect(spec.bounds.x + horizontal, spec.bounds.y + vertical, + spec.bounds.width - horizontal - trailing, + spec.bounds.height - vertical * 2); +} + +fn surface_spec_valid(spec: SurfaceSpec) i32 { + if (node_key_equal(spec.id, hit_root_key()) != 0 || + node_key_equal(spec.id, semantic_none_key()) != 0 || + node_key_equal(spec.parent, semantic_none_key()) != 0 || + spec.name.len <= 0 || spec.name.len > surface_name_byte_limit() || + spec.description.len > surface_description_byte_limit() || + semantic_text_input_valid(spec.name) == 0 || + semantic_text_input_valid(spec.description) == 0 || + surface_tier_valid(spec.tier) == 0 || + surface_mode_valid(spec.mode) == 0 || + surface_density_valid(spec.density) == 0 || + surface_live_region_valid(spec.live_region) == 0 || + hit_geometry_valid(spec.bounds) == 0 || + spec.focus_order < 0 || spec.focus_order > 1000000000 || + spec.z_order < 0 || spec.z_order > 1000000000 || + spec.limits.display_operations < 0 || spec.limits.hit_nodes < 0 || + spec.limits.semantic_nodes < 0) { return 0; } + if (spec.bounds.width < surface_minimum_extent(spec) || + spec.bounds.height < surface_minimum_extent(spec)) { return 0; } + if (spec.mode == SurfaceMode.group) { + if (spec.focus_order != 0 || + surface_group_interaction_valid(spec.interaction) == 0) { return 0; } + } else { + if (spec.focus_order <= 0 || + surface_action_interaction_valid(spec.interaction) == 0) { return 0; } + } + let content: Rect = surface_content_bounds(spec); + return (content.width > 0 && content.height > 0) as i32; +} + +fn surface_state_value(spec: SurfaceSpec) []u8 { + if (spec.mode == SurfaceMode.group) { return "Content group"; } + if (spec.interaction.loading != 0) { return "Loading"; } + if (spec.interaction.error != 0) { return "Error"; } + if (spec.interaction.enabled == 0) { return "Disabled"; } + if (spec.interaction.pressed != 0) { return "Pressed"; } + if (spec.interaction.selected != 0) { return "Selected"; } + if (spec.interaction.focused != 0) { return "Focused"; } + if (spec.interaction.hovered != 0) { return "Hovered"; } + return "Ready"; +} + +fn surface_semantic_role(spec: SurfaceSpec) SemanticRole { + if (spec.mode == SurfaceMode.actionable) { return SemanticRole.button; } + return SemanticRole.group; +} + +fn surface_effectively_enabled(spec: SurfaceSpec) i32 { + if (spec.mode != SurfaceMode.actionable) { return 0; } + return button_effectively_enabled(spec.interaction); +} + +fn surface_mix(hash: i64, value: i64) i64 { + return (hash * 1099511628211) ^ value; +} + +fn surface_text_hash(hash: i64, value: []u8) i64 { + let result: i64 = surface_mix(hash, value.len as i64); + let index: i32 = 0; + while (index < value.len) { + result = surface_mix(result, value[index] as i64); + index = index + 1; + } + return result; +} + +fn surface_evidence_hash(spec: SurfaceSpec, + artifact: SurfaceArtifact) i64 { + let hash: i64 = surface_mix(17, artifact.id.value); + hash = surface_mix(hash, artifact.id.generation); + hash = surface_mix(hash, spec.parent.value); + hash = surface_mix(hash, spec.parent.generation); + hash = surface_mix(hash, artifact.bounds.x); + hash = surface_mix(hash, artifact.bounds.y); + hash = surface_mix(hash, artifact.bounds.width); + hash = surface_mix(hash, artifact.bounds.height); + hash = surface_mix(hash, artifact.chrome_bounds.x); + hash = surface_mix(hash, artifact.chrome_bounds.y); + hash = surface_mix(hash, artifact.chrome_bounds.width); + hash = surface_mix(hash, artifact.chrome_bounds.height); + hash = surface_mix(hash, artifact.content_bounds.x); + hash = surface_mix(hash, artifact.content_bounds.y); + hash = surface_mix(hash, artifact.content_bounds.width); + hash = surface_mix(hash, artifact.content_bounds.height); + hash = surface_mix(hash, artifact.tier as i64); + hash = surface_mix(hash, artifact.mode as i64); + hash = surface_mix(hash, spec.focus_order); + hash = surface_mix(hash, spec.z_order); + hash = surface_mix(hash, spec.density as i64); + hash = surface_mix(hash, spec.live_region as i64); + hash = surface_mix(hash, artifact.interaction.hovered as i64); + hash = surface_mix(hash, artifact.interaction.focused as i64); + hash = surface_mix(hash, artifact.interaction.focus_visible as i64); + hash = surface_mix(hash, artifact.interaction.pressed as i64); + hash = surface_mix(hash, artifact.interaction.selected as i64); + hash = surface_mix(hash, artifact.interaction.enabled as i64); + hash = surface_mix(hash, artifact.interaction.loading as i64); + hash = surface_mix(hash, artifact.interaction.error as i64); + hash = surface_mix(hash, artifact.style.material_token as i64); + hash = surface_mix(hash, artifact.style.material_fill_token as i64); + hash = surface_mix(hash, artifact.style.fill_token as i64); + hash = surface_mix(hash, artifact.style.edge_token as i64); + hash = surface_mix(hash, artifact.style.border_token as i64); + hash = surface_mix(hash, artifact.style.content_color_token as i64); + hash = surface_mix(hash, artifact.style.focus_token as i64); + hash = surface_mix(hash, artifact.style.state_token as i64); + hash = surface_mix(hash, artifact.style.radius_token as i64); + hash = surface_mix(hash, artifact.style.material_elevation_token as i64); + hash = surface_mix(hash, artifact.style.elevation_token as i64); + hash = surface_mix(hash, artifact.style.content_spacing_token as i64); + hash = surface_mix(hash, artifact.style.fill_alpha); + hash = surface_mix(hash, artifact.style.border_alpha); + hash = surface_mix(hash, artifact.resolved_radius); + hash = surface_mix(hash, artifact.effectively_enabled as i64); + hash = surface_mix(hash, artifact.hover_treatment_visible as i64); + hash = surface_mix(hash, artifact.focus_ring_visible as i64); + hash = surface_mix(hash, artifact.pressed_treatment_visible as i64); + hash = surface_mix(hash, artifact.selected_marker_visible as i64); + hash = surface_mix(hash, artifact.disabled_treatment_visible as i64); + hash = surface_mix(hash, artifact.loading_treatment_visible as i64); + hash = surface_mix(hash, artifact.error_treatment_visible as i64); + hash = surface_mix(hash, artifact.semantics_role as i64); + hash = surface_text_hash(hash, spec.name); + return surface_text_hash(hash, spec.description); +} + +fn surface_empty_artifact(spec: SurfaceSpec) SurfaceArtifact { + let style: SurfaceResolvedStyle = surface_style(spec); + let chrome: Rect = surface_chrome_bounds(spec); + return SurfaceArtifact{ + .id = spec.id, + .bounds = spec.bounds, + .chrome_bounds = chrome, + .content_bounds = rect(0, 0, 0, 0), + .tier = spec.tier, + .mode = spec.mode, + .interaction = spec.interaction, + .style = style, + .resolved_radius = surface_resolved_radius(style.radius_token, chrome), + .effectively_enabled = 0, + .hover_treatment_visible = 0, + .focus_ring_visible = 0, + .pressed_treatment_visible = 0, + .selected_marker_visible = 0, + .disabled_treatment_visible = 0, + .loading_treatment_visible = 0, + .error_treatment_visible = 0, + .semantics_role = surface_semantic_role(spec), + .display_op_start = 0 - 1, + .display_op_end = 0 - 1, + .hit_node_index = 0 - 1, + .semantics_node_index = 0 - 1, + .evidence_hash = 0, + }; +} + +fn surface_build_result(spec: SurfaceSpec, + error: SurfaceBuildError) SurfaceBuildResult { + return SurfaceBuildResult{ + .artifact = surface_empty_artifact(spec), + .error = error, + .capacity_error = SurfaceCapacityError.none, + .display_error = DisplayError.none, + .hit_error = HitTreeError.none, + .semantics_error = SemanticsError.none, + }; +} + +fn surface_checkpoint(display: *DisplayList, + hits: *HitTree, + semantics: *SemanticsTree) SurfaceCheckpoint { + return SurfaceCheckpoint{ + .display_length = display.*.ops.len, + .display_revision = display.*.revision, + .display_hash = display.*.content_hash, + .display_last_error = display.*.last_error, + .display_error_index = display.*.error_index, + .hit_length = hits.*.nodes.len, + .hit_revision = hits.*.revision, + .hit_last_error = hits.*.last_error, + .hit_error_node = hits.*.error_node, + .semantics = semantics_tree_checkpoint(semantics.*), + }; +} + +fn surface_rollback(display: *DisplayList, + hits: *HitTree, + semantics: *SemanticsTree, + checkpoint: SurfaceCheckpoint) void { + display.*.ops.len = checkpoint.display_length; + display.*.revision = checkpoint.display_revision; + display.*.content_hash = checkpoint.display_hash; + display.*.last_error = checkpoint.display_last_error; + display.*.error_index = checkpoint.display_error_index; + hits.*.nodes.len = checkpoint.hit_length; + hits.*.revision = checkpoint.hit_revision; + hits.*.last_error = checkpoint.hit_last_error; + hits.*.error_node = checkpoint.hit_error_node; + _ = semantics_tree_rollback(semantics, checkpoint.semantics); +} + +fn surface_budget(limits: SurfaceBuildLimits) SurfaceBuildBudget { + return SurfaceBuildBudget{ + .limits = limits, + .display_operations = 0, + .hit_nodes = 0, + .semantic_nodes = 0, + }; +} + +fn surface_take_display(budget: *SurfaceBuildBudget) i32 { + if (budget.*.display_operations >= budget.*.limits.display_operations) { + return 0; + } + budget.*.display_operations = budget.*.display_operations + 1; + return 1; +} + +fn surface_take_hit(budget: *SurfaceBuildBudget) i32 { + if (budget.*.hit_nodes >= budget.*.limits.hit_nodes) { return 0; } + budget.*.hit_nodes = budget.*.hit_nodes + 1; + return 1; +} + +fn surface_take_semantic(budget: *SurfaceBuildBudget) i32 { + if (budget.*.semantic_nodes >= budget.*.limits.semantic_nodes) { return 0; } + budget.*.semantic_nodes = budget.*.semantic_nodes + 1; + return 1; +} + +fn surface_draw_result() SurfaceDrawResult { + return SurfaceDrawResult{ + .error = SurfaceBuildError.none, + .capacity_error = SurfaceCapacityError.none, + .display_error = DisplayError.none, + }; +} + +fn surface_push_round(display: *DisplayList, + budget: *SurfaceBuildBudget, + owner: NodeKey, bounds: Rect, radius: i64, + paint: Paint) SurfaceDrawResult { + let result: SurfaceDrawResult = surface_draw_result(); + if (surface_take_display(budget) == 0) { + result.error = SurfaceBuildError.capacity_error; + result.capacity_error = SurfaceCapacityError.display_operations; + return result; + } + let op: DisplayOp = display_op(DisplayOpKind.fill_rounded_rect, owner); + op.bounds = bounds; + op.parameter = radius; + op.paint = paint; + let error: DisplayError = display_list_push(display, op); + if (error != DisplayError.none) { + result.error = SurfaceBuildError.display_error; + result.display_error = error; + } + return result; +} + +fn surface_push_rect(display: *DisplayList, + budget: *SurfaceBuildBudget, + owner: NodeKey, bounds: Rect, paint: Paint) SurfaceDrawResult { + let result: SurfaceDrawResult = surface_draw_result(); + if (surface_take_display(budget) == 0) { + result.error = SurfaceBuildError.capacity_error; + result.capacity_error = SurfaceCapacityError.display_operations; + return result; + } + let op: DisplayOp = display_op(DisplayOpKind.fill_rect, owner); + op.bounds = bounds; + op.paint = paint; + let error: DisplayError = display_list_push(display, op); + if (error != DisplayError.none) { + result.error = SurfaceBuildError.display_error; + result.display_error = error; + } + return result; +} + +fn surface_copy_draw_failure(result: *SurfaceBuildResult, + draw: SurfaceDrawResult) void { + result.*.error = draw.error; + result.*.capacity_error = draw.capacity_error; + result.*.display_error = draw.display_error; +} + +fn surface_emit(spec: SurfaceSpec, + display: *DisplayList, + hits: *HitTree, + semantics: *SemanticsTree) SurfaceBuildResult { + let result: SurfaceBuildResult = surface_build_result( + spec, SurfaceBuildError.none); + if (surface_spec_valid(spec) == 0) { + result.error = SurfaceBuildError.invalid_spec; + return result; + } + if (display.*.sealed != 0) { + result.error = SurfaceBuildError.display_error; + result.display_error = DisplayError.sealed; + return result; + } + + let checkpoint: SurfaceCheckpoint = surface_checkpoint(display, + hits, semantics); + let budget: SurfaceBuildBudget = surface_budget(spec.limits); + let style: SurfaceResolvedStyle = surface_style(spec); + let chrome: Rect = surface_chrome_bounds(spec); + let radius: i64 = surface_resolved_radius(style.radius_token, chrome); + let enabled: i32 = surface_effectively_enabled(spec); + let elevation: ResolvedElevationToken = semantic_elevation( + style.elevation_token); + let draw: SurfaceDrawResult = surface_draw_result(); + + if (spec.mode == SurfaceMode.actionable && + spec.interaction.focus_visible != 0 && enabled != 0) { + let extent: i64 = 3 * unit_scale(); + draw = surface_push_round(display, &budget, spec.id, + rect(chrome.x - extent, chrome.y - extent, + chrome.width + extent * 2, chrome.height + extent * 2), + radius + extent, + semantic_color_with_alpha(style.focus_token, 43000)); + } + if (draw.error == SurfaceBuildError.none && + elevation.ambient_shadow_alpha > 0 && + spec.interaction.pressed == 0 && + (spec.mode == SurfaceMode.group || enabled != 0)) { + let spread: i64 = elevation.ambient_shadow_blur; + draw = surface_push_round(display, &budget, spec.id, + rect(chrome.x - spread, + chrome.y + elevation.ambient_shadow_y - spread, + chrome.width + spread * 2, + chrome.height + spread * 2), + radius + spread, + semantic_color_with_alpha(SemanticColorToken.shadow, + elevation.ambient_shadow_alpha)); + if (draw.error == SurfaceBuildError.none && + elevation.contact_shadow_alpha > 0) { + let contact_spread: i64 = elevation.contact_shadow_blur; + draw = surface_push_round(display, &budget, spec.id, + rect(chrome.x - contact_spread, + chrome.y + elevation.contact_shadow_y - contact_spread, + chrome.width + contact_spread * 2, + chrome.height + contact_spread * 2), + radius + contact_spread, + semantic_color_with_alpha(SemanticColorToken.shadow, + elevation.contact_shadow_alpha)); + } + } + if (draw.error == SurfaceBuildError.none) { + draw = surface_push_round(display, &budget, spec.id, + chrome, radius, + semantic_color_with_alpha(style.border_token, + style.border_alpha)); + } + if (draw.error == SurfaceBuildError.none) { + let edge: i64 = unit_scale(); + draw = surface_push_round(display, &budget, spec.id, + rect(chrome.x + edge, chrome.y + edge, + chrome.width - edge * 2, chrome.height - edge * 2), + radius - edge, + semantic_color_with_alpha(style.fill_token, style.fill_alpha)); + } + if (draw.error == SurfaceBuildError.none) { + let edge_inset: i64 = flex_spacing(FlexSpacingToken.small, + spec.density); + draw = surface_push_rect(display, &budget, spec.id, + rect(chrome.x + edge_inset, chrome.y + unit_scale(), + chrome.width - edge_inset * 2, unit_scale()), + semantic_color_with_alpha(style.edge_token, + 13000 + (spec.tier as i64) * 3000)); + } + if (draw.error == SurfaceBuildError.none && + spec.mode == SurfaceMode.actionable && spec.interaction.hovered != 0) { + let marker_width: i64 = 3 * unit_scale(); + let marker_height: i64 = 18 * unit_scale(); + draw = surface_push_round(display, &budget, spec.id, + rect(chrome.x + chrome.width - marker_width - 5 * unit_scale(), + chrome.y + (chrome.height - marker_height) / 2, + marker_width, marker_height), unit_scale(), + semantic_color_with_alpha(style.state_token, 65535)); + } + if (draw.error == SurfaceBuildError.none && + spec.mode == SurfaceMode.actionable && spec.interaction.selected != 0) { + let marker_height: i64 = 3 * unit_scale(); + let inset: i64 = flex_spacing(FlexSpacingToken.large, spec.density); + draw = surface_push_round(display, &budget, spec.id, + rect(chrome.x + inset, + chrome.y + chrome.height - marker_height - + flex_spacing(FlexSpacingToken.tiny, spec.density), + chrome.width - inset * 2, marker_height), + unit_scale(), + semantic_color_with_alpha(style.state_token, 65535)); + } + if (draw.error == SurfaceBuildError.none && + spec.mode == SurfaceMode.actionable && spec.interaction.loading != 0) { + let track_width: i64 = 22 * unit_scale(); + let track_height: i64 = 4 * unit_scale(); + let track_x: i64 = chrome.x + chrome.width - + flex_spacing(FlexSpacingToken.large, spec.density) - track_width; + let track_y: i64 = chrome.y + (chrome.height - track_height) / 2; + draw = surface_push_round(display, &budget, spec.id, + rect(track_x, track_y, track_width, track_height), + track_height / 2, + semantic_color_with_alpha(style.state_token, 21000)); + if (draw.error == SurfaceBuildError.none) { + draw = surface_push_round(display, &budget, spec.id, + rect(track_x, track_y, 13 * unit_scale(), track_height), + track_height / 2, + semantic_color_with_alpha(style.state_token, 65535)); + } + } + if (draw.error == SurfaceBuildError.none && + spec.mode == SurfaceMode.actionable && spec.interaction.error != 0) { + let rail_width: i64 = flex_spacing(FlexSpacingToken.tiny, + spec.density); + let rail_height: i64 = flex_spacing(FlexSpacingToken.xlarge, + spec.density); + let rail_x: i64 = chrome.x + chrome.width - + flex_spacing(FlexSpacingToken.large, spec.density) - rail_width; + let rail_y: i64 = chrome.y + (chrome.height - rail_height) / 2; + draw = surface_push_round(display, &budget, spec.id, + rect(rail_x, rail_y, rail_width, rail_height), + rail_width / 2, + semantic_color_with_alpha(style.state_token, 65535)); + } + if (draw.error == SurfaceBuildError.none && + spec.mode == SurfaceMode.actionable && spec.interaction.enabled == 0) { + let mark_width: i64 = 14 * unit_scale(); + let mark_x: i64 = chrome.x + chrome.width - + flex_spacing(FlexSpacingToken.large, spec.density) - mark_width; + let mark_y: i64 = chrome.y + chrome.height / 2 - 3 * unit_scale(); + draw = surface_push_rect(display, &budget, spec.id, + rect(mark_x, mark_y, mark_width, 2 * unit_scale()), + semantic_color_with_alpha(style.state_token, 50000)); + if (draw.error == SurfaceBuildError.none) { + draw = surface_push_rect(display, &budget, spec.id, + rect(mark_x, mark_y + 5 * unit_scale(), + 9 * unit_scale(), 2 * unit_scale()), + semantic_color_with_alpha(style.state_token, 30000)); + } + } + if (draw.error != SurfaceBuildError.none) { + surface_rollback(display, hits, semantics, checkpoint); + surface_copy_draw_failure(&result, draw); + return result; + } + + if (surface_take_hit(&budget) == 0) { + surface_rollback(display, hits, semantics, checkpoint); + result.error = SurfaceBuildError.capacity_error; + result.capacity_error = SurfaceCapacityError.hit_nodes; + return result; + } + let hit: HitNode = hit_node(spec.id, spec.parent, spec.bounds); + hit.clip = spec.bounds; + hit.has_clip = 1; + hit.z_order = spec.z_order; + hit.enabled = enabled; + hit.focusable = enabled; + let hit_error: HitTreeError = hit_tree_add(hits, hit); + if (hit_error != HitTreeError.none) { + surface_rollback(display, hits, semantics, checkpoint); + result.error = SurfaceBuildError.hit_error; + result.hit_error = hit_error; + return result; + } + + if (surface_take_semantic(&budget) == 0) { + surface_rollback(display, hits, semantics, checkpoint); + result.error = SurfaceBuildError.capacity_error; + result.capacity_error = SurfaceCapacityError.semantic_nodes; + return result; + } + let semantic: SemanticsSpec = semantics_spec(spec.id, spec.parent, + surface_semantic_role(spec), spec.name); + semantic.description = spec.description; + semantic.value = surface_state_value(spec); + semantic.has_bounds = 1; + semantic.bounds = spec.bounds; + semantic.live_region = spec.live_region; + if (spec.mode == SurfaceMode.actionable) { + semantic.focus_order = spec.focus_order; + semantic.disabled = (enabled == 0) as i32; + semantic.focused = (spec.interaction.focused != 0 && enabled != 0) as i32; + semantic.selected = spec.interaction.selected; + if (enabled != 0) { + semantic.action_mask = semantic_actions_add( + semantic_actions(SemanticAction.activate), + SemanticAction.focus); + } + } + let semantic_error: SemanticsError = semantics_add(semantics, semantic); + if (semantic_error != SemanticsError.none) { + surface_rollback(display, hits, semantics, checkpoint); + result.error = SurfaceBuildError.semantics_error; + result.semantics_error = semantic_error; + return result; + } + + result.artifact = SurfaceArtifact{ + .id = spec.id, + .bounds = spec.bounds, + .chrome_bounds = chrome, + .content_bounds = surface_content_bounds(spec), + .tier = spec.tier, + .mode = spec.mode, + .interaction = spec.interaction, + .style = style, + .resolved_radius = radius, + .effectively_enabled = enabled, + .hover_treatment_visible = (spec.mode == SurfaceMode.actionable && + spec.interaction.hovered != 0) as i32, + .focus_ring_visible = (spec.mode == SurfaceMode.actionable && + spec.interaction.focus_visible != 0 && enabled != 0) as i32, + .pressed_treatment_visible = (spec.mode == SurfaceMode.actionable && + spec.interaction.pressed != 0) as i32, + .selected_marker_visible = (spec.mode == SurfaceMode.actionable && + spec.interaction.selected != 0) as i32, + .disabled_treatment_visible = (spec.mode == SurfaceMode.actionable && + spec.interaction.enabled == 0) as i32, + .loading_treatment_visible = (spec.mode == SurfaceMode.actionable && + spec.interaction.loading != 0) as i32, + .error_treatment_visible = (spec.mode == SurfaceMode.actionable && + spec.interaction.error != 0) as i32, + .semantics_role = surface_semantic_role(spec), + .display_op_start = checkpoint.display_length, + .display_op_end = display.*.ops.len, + .hit_node_index = checkpoint.hit_length, + .semantics_node_index = checkpoint.semantics.length, + .evidence_hash = 0, + }; + result.artifact.evidence_hash = surface_evidence_hash(spec, + result.artifact); + return result; +} diff --git a/src/design/tokens.zag b/src/design/tokens.zag index e81afbb..06e5962 100644 --- a/src/design/tokens.zag +++ b/src/design/tokens.zag @@ -48,15 +48,15 @@ fn semantic_color_token_count() i32 { return 31; } fn semantic_color(token: SemanticColorToken) ResolvedColorToken { return switch (token) { .canvas => ResolvedColorToken{ .id = "color.canvas", - .paint = paint_rgba16(2200, 3100, 6500, 65535) }, + .paint = paint_rgba16(2827, 3598, 5140, 65535) }, .surface_base => ResolvedColorToken{ .id = "color.surface.base", - .paint = paint_rgba16(7200, 10500, 19000, 50000) }, + .paint = paint_rgba16(4626, 5654, 7967, 65535) }, .surface_panel => ResolvedColorToken{ .id = "color.surface.panel", - .paint = paint_rgba16(7000, 12000, 22500, 56000) }, + .paint = paint_rgba16(5911, 7196, 10023, 65535) }, .surface_raised => ResolvedColorToken{ .id = "color.surface.raised", - .paint = paint_rgba16(9000, 15500, 27500, 60000) }, + .paint = paint_rgba16(7710, 9252, 12593, 65535) }, .surface_inset => ResolvedColorToken{ .id = "color.surface.inset", - .paint = paint_rgba16(4500, 7600, 14500, 56000) }, + .paint = paint_rgba16(3341, 4112, 6168, 65535) }, .surface_interactive => ResolvedColorToken{ .id = "color.surface.interactive", .paint = paint_rgba16(9000, 18000, 34000, 56000) }, .surface_selected => ResolvedColorToken{ .id = "color.surface.selected", @@ -66,23 +66,23 @@ fn semantic_color(token: SemanticColorToken) ResolvedColorToken { .border_emphasis => ResolvedColorToken{ .id = "color.border.emphasis", .paint = paint_rgba16(32000, 47000, 65535, 21000) }, .text_primary => ResolvedColorToken{ .id = "color.text.primary", - .paint = paint_rgba16(57000, 61000, 65535, 65535) }, + .paint = paint_rgba16(61680, 62708, 64764, 65535) }, .text_secondary => ResolvedColorToken{ .id = "color.text.secondary", - .paint = paint_rgba16(35000, 43000, 56000, 60000) }, + .paint = paint_rgba16(43433, 46260, 51400, 60000) }, .text_disabled => ResolvedColorToken{ .id = "color.text.disabled", - .paint = paint_rgba16(28000, 34000, 43000, 43000) }, + .paint = paint_rgba16(23130, 25700, 30840, 43000) }, .accent => ResolvedColorToken{ .id = "color.accent", - .paint = paint_rgba16(17000, 48500, 65535, 62000) }, + .paint = paint_rgba16(23387, 23387, 61680, 65535) }, .accent_emphasis => ResolvedColorToken{ .id = "color.accent.emphasis", - .paint = paint_rgba16(12500, 38500, 61000, 65535) }, + .paint = paint_rgba16(31868, 31868, 62965, 65535) }, .focus => ResolvedColorToken{ .id = "color.focus", - .paint = paint_rgba16(26000, 60000, 65535, 65535) }, + .paint = paint_rgba16(14135, 54998, 61166, 65535) }, .status_healthy => ResolvedColorToken{ .id = "color.status.healthy", - .paint = paint_rgba16(18000, 56000, 42000, 65535) }, + .paint = paint_rgba16(16191, 49087, 31354, 65535) }, .status_experimental => ResolvedColorToken{ .id = "color.status.experimental", - .paint = paint_rgba16(65535, 43000, 17000, 65535) }, + .paint = paint_rgba16(59624, 41377, 15677, 65535) }, .status_error => ResolvedColorToken{ .id = "color.status.error", - .paint = paint_rgba16(65535, 17500, 23500, 65535) }, + .paint = paint_rgba16(61680, 21845, 19789, 65535) }, .category_semantics => ResolvedColorToken{ .id = "color.category.semantics", .paint = paint_rgba16(15000, 48000, 65535, 65535) }, .category_motion => ResolvedColorToken{ .id = "color.category.motion", @@ -102,13 +102,13 @@ fn semantic_color(token: SemanticColorToken) ResolvedColorToken { .chart_series_tertiary => ResolvedColorToken{ .id = "color.chart.series.tertiary", .paint = paint_rgba16(65535, 43000, 17000, 59000) }, .shadow => ResolvedColorToken{ .id = "color.shadow", - .paint = paint_rgba16(0, 0, 0, 12000) }, + .paint = paint_rgba16(514, 1542, 4112, 27525) }, .edge_highlight => ResolvedColorToken{ .id = "color.edge.highlight", - .paint = paint_rgba16(42000, 56000, 65535, 19000) }, + .paint = paint_rgba16(48830, 53970, 65535, 10486) }, .ambient_cool => ResolvedColorToken{ .id = "color.ambient.cool", - .paint = paint_rgba16(13000, 39000, 65535, 8000) }, + .paint = paint_rgba16(20560, 35980, 65535, 3932) }, .ambient_warm => ResolvedColorToken{ .id = "color.ambient.warm", - .paint = paint_rgba16(58000, 18000, 47000, 7000) }, + .paint = paint_rgba16(59110, 30840, 48830, 3277) }, }; } @@ -155,8 +155,8 @@ fn semantic_radius_name(token: SemanticRadiusToken) []u8 { fn semantic_radius(token: SemanticRadiusToken) i64 { return switch (token) { - .control => 12 * unit_scale(), .card => 18 * unit_scale(), - .panel => 24 * unit_scale(), .pill => 999 * unit_scale(), + .control => 10 * unit_scale(), .card => 14 * unit_scale(), + .panel => 16 * unit_scale(), .pill => 999 * unit_scale(), }; } @@ -164,35 +164,48 @@ enum SemanticElevationToken { base, panel, raised, overlay } struct ResolvedElevationToken { id: []u8, - shadow_offset_y: i64, - shadow_spread: i64, - shadow_alpha: i64, - border_alpha: i64, + contact_shadow_y: i64, + contact_shadow_blur: i64, + contact_shadow_alpha: i64, + ambient_shadow_y: i64, + ambient_shadow_blur: i64, + ambient_shadow_alpha: i64, + edge_highlight_alpha: i64, } fn semantic_elevation(token: SemanticElevationToken) ResolvedElevationToken { return switch (token) { .base => ResolvedElevationToken{ .id = "elevation.base", - .shadow_offset_y = 0, .shadow_spread = 0, - .shadow_alpha = 0, .border_alpha = 9000 }, + .contact_shadow_y = 0, .contact_shadow_blur = 0, + .contact_shadow_alpha = 0, + .ambient_shadow_y = 0, .ambient_shadow_blur = 0, + .ambient_shadow_alpha = 0, + .edge_highlight_alpha = 6554 }, .panel => ResolvedElevationToken{ .id = "elevation.panel", - .shadow_offset_y = 4 * unit_scale(), .shadow_spread = 3 * unit_scale(), - .shadow_alpha = 9000, .border_alpha = 13000 }, + .contact_shadow_y = 1 * unit_scale(), .contact_shadow_blur = 4 * unit_scale(), + .contact_shadow_alpha = 19661, + .ambient_shadow_y = 6 * unit_scale(), .ambient_shadow_blur = 18 * unit_scale(), + .ambient_shadow_alpha = 10486, + .edge_highlight_alpha = 9175 }, .raised => ResolvedElevationToken{ .id = "elevation.raised", - .shadow_offset_y = 7 * unit_scale(), .shadow_spread = 5 * unit_scale(), - .shadow_alpha = 13000, .border_alpha = 18000 }, + .contact_shadow_y = 2 * unit_scale(), .contact_shadow_blur = 6 * unit_scale(), + .contact_shadow_alpha = 22282, + .ambient_shadow_y = 10 * unit_scale(), .ambient_shadow_blur = 28 * unit_scale(), + .ambient_shadow_alpha = 13107, + .edge_highlight_alpha = 11796 }, .overlay => ResolvedElevationToken{ .id = "elevation.overlay", - .shadow_offset_y = 12 * unit_scale(), .shadow_spread = 8 * unit_scale(), - .shadow_alpha = 18000, .border_alpha = 23000 }, + .contact_shadow_y = 3 * unit_scale(), .contact_shadow_blur = 8 * unit_scale(), + .contact_shadow_alpha = 24904, + .ambient_shadow_y = 16 * unit_scale(), .ambient_shadow_blur = 40 * unit_scale(), + .ambient_shadow_alpha = 17039, + .edge_highlight_alpha = 14418 }, }; } enum SemanticMaterialToken { - showcase_backdrop, - shell, - panel, - raised, - overlay, + solid, + veil, + glass, } struct ResolvedMaterialToken { @@ -208,64 +221,42 @@ struct ResolvedMaterialToken { cpu_fallback_reason: []u8, } -fn semantic_material_token_count() i32 { return 5; } +fn semantic_material_token_count() i32 { return 3; } fn semantic_material(token: SemanticMaterialToken) ResolvedMaterialToken { return switch (token) { - .showcase_backdrop => ResolvedMaterialToken{ - .id = "material.showcase.backdrop", - .fill_token = SemanticColorToken.canvas, - .tint_token = SemanticColorToken.ambient_cool, - .edge_token = SemanticColorToken.border_subtle, - .radius_token = SemanticRadiusToken.control, - .elevation_token = SemanticElevationToken.base, - .tint_alpha_start = 1500, .tint_alpha_end = 12500, - .blur_radius = 0, - .cpu_fallback_reason = "retained color bands are the deterministic CPU gradient oracle", - }, - .shell => ResolvedMaterialToken{ - .id = "material.glass.shell", + .solid => ResolvedMaterialToken{ + .id = "material.solid", .fill_token = SemanticColorToken.surface_base, .tint_token = SemanticColorToken.ambient_cool, - .edge_token = SemanticColorToken.edge_highlight, + .edge_token = SemanticColorToken.border_subtle, .radius_token = SemanticRadiusToken.panel, .elevation_token = SemanticElevationToken.base, .tint_alpha_start = 0, .tint_alpha_end = 0, - .blur_radius = 18 * unit_scale(), - .cpu_fallback_reason = "translucent fill and edge remain authoritative until backdrop blur lands", + .blur_radius = 0, + .cpu_fallback_reason = "exact fill plus analytic gradient bands", }, - .panel => ResolvedMaterialToken{ - .id = "material.glass.panel", + .veil => ResolvedMaterialToken{ + .id = "material.veil", .fill_token = SemanticColorToken.surface_panel, .tint_token = SemanticColorToken.ambient_cool, .edge_token = SemanticColorToken.edge_highlight, .radius_token = SemanticRadiusToken.panel, .elevation_token = SemanticElevationToken.panel, - .tint_alpha_start = 0, .tint_alpha_end = 0, - .blur_radius = 24 * unit_scale(), - .cpu_fallback_reason = "translucent fill edge and shadow are the current CPU material", + .tint_alpha_start = 0, .tint_alpha_end = 12000, + .blur_radius = 20 * unit_scale(), + .cpu_fallback_reason = "vertical ambient-tint band plus edge highlight, fallback reason recorded", }, - .raised => ResolvedMaterialToken{ - .id = "material.glass.raised", - .fill_token = SemanticColorToken.surface_raised, - .tint_token = SemanticColorToken.ambient_cool, - .edge_token = SemanticColorToken.edge_highlight, - .radius_token = SemanticRadiusToken.card, - .elevation_token = SemanticElevationToken.raised, - .tint_alpha_start = 0, .tint_alpha_end = 0, - .blur_radius = 30 * unit_scale(), - .cpu_fallback_reason = "translucent fill edge and raised shadow are the current CPU material", - }, - .overlay => ResolvedMaterialToken{ - .id = "material.glass.overlay", + .glass => ResolvedMaterialToken{ + .id = "material.glass", .fill_token = SemanticColorToken.surface_raised, .tint_token = SemanticColorToken.ambient_warm, .edge_token = SemanticColorToken.edge_highlight, .radius_token = SemanticRadiusToken.card, - .elevation_token = SemanticElevationToken.overlay, - .tint_alpha_start = 0, .tint_alpha_end = 0, + .elevation_token = SemanticElevationToken.raised, + .tint_alpha_start = 0, .tint_alpha_end = 14000, .blur_radius = 36 * unit_scale(), - .cpu_fallback_reason = "translucent fill edge and overlay shadow are the current CPU material", + .cpu_fallback_reason = "warm ambient-tint band plus overlay shadow pair, fallback reason recorded", }, }; } diff --git a/src/input/hit_test.zag b/src/input/hit_test.zag index ca8a346..75b1159 100644 --- a/src/input/hit_test.zag +++ b/src/input/hit_test.zag @@ -78,6 +78,10 @@ fn hit_tree_find_index(tree: HitTree, id: NodeKey) i32 { return 0 - 1; } +fn hit_tree_find_index_borrowed(tree: *HitTree, id: NodeKey) i32 { + return hit_tree_find_index(tree.*, id); +} + fn hit_transform_values_valid(transform: Transform) i32 { let limit: i64 = 4096 * unit_scale(); if (transform.scale_x < 0 - limit || transform.scale_x > limit || diff --git a/src/platform/linux/x11.zag b/src/platform/linux/x11.zag index e1cbebc..bbc5f93 100644 --- a/src/platform/linux/x11.zag +++ b/src/platform/linux/x11.zag @@ -4,43 +4,43 @@ // Xlib is used only as the public Linux window-system transport. Layout, // rendering, resources, state, semantics, and pixels remain owned by Zagkit. // This fallback never promotes itself to Wayland or accessibility support. -extern fn XOpenDisplay(name: *u8) *u8; -extern fn XDefaultScreen(display: *u8) i32; -extern fn XRootWindow(display: *u8, screen: i32) i64; -extern fn XDefaultVisual(display: *u8, screen: i32) *u8; -extern fn XDefaultDepth(display: *u8, screen: i32) i32; -extern fn XCreateSimpleWindow(display: *u8, parent: i64, x: i32, y: i32, +@cabi extern fn XOpenDisplay(name: *u8) *u8; +@cabi extern fn XDefaultScreen(display: *u8) i32; +@cabi extern fn XRootWindow(display: *u8, screen: i32) i64; +@cabi extern fn XDefaultVisual(display: *u8, screen: i32) *u8; +@cabi extern fn XDefaultDepth(display: *u8, screen: i32) i32; +@cabi extern fn XCreateSimpleWindow(display: *u8, parent: i64, x: i32, y: i32, width: i32, height: i32, border_width: i32, border: i64, background: i64) i64; -extern fn XCreateGC(display: *u8, drawable: i64, value_mask: i64, +@cabi extern fn XCreateGC(display: *u8, drawable: i64, value_mask: i64, values: *u8) *u8; -extern fn XSelectInput(display: *u8, window: i64, event_mask: i64) i32; -extern fn XStoreName(display: *u8, window: i64, name: *u8) i32; -extern fn XChangeProperty(display: *u8, window: i64, property: i64, +@cabi extern fn XSelectInput(display: *u8, window: i64, event_mask: i64) i32; +@cabi extern fn XStoreName(display: *u8, window: i64, name: *u8) i32; +@cabi extern fn XChangeProperty(display: *u8, window: i64, property: i64, property_type: i64, format: i32, mode: i32, data: *u8, element_count: i32) i32; -extern fn XInternAtom(display: *u8, name: *u8, only_if_exists: i32) i64; -extern fn XSetWMProtocols(display: *u8, window: i64, protocols: *i64, +@cabi extern fn XInternAtom(display: *u8, name: *u8, only_if_exists: i32) i64; +@cabi extern fn XSetWMProtocols(display: *u8, window: i64, protocols: *i64, count: i32) i32; -extern fn XMapWindow(display: *u8, window: i64) i32; -extern fn XCreateImage(display: *u8, visual: *u8, depth: i32, format: i32, +@cabi extern fn XMapWindow(display: *u8, window: i64) i32; +@cabi extern fn XCreateImage(display: *u8, visual: *u8, depth: i32, format: i32, offset: i32, data: *u8, width: i32, height: i32, bitmap_pad: i32, bytes_per_line: i32) *u8; -extern fn XPutImage(display: *u8, drawable: i64, gc: *u8, image: *u8, +@cabi extern fn XPutImage(display: *u8, drawable: i64, gc: *u8, image: *u8, src_x: i32, src_y: i32, dest_x: i32, dest_y: i32, width: i32, height: i32) i32; -extern fn XPending(display: *u8) i32; -extern fn XNextEvent(display: *u8, event: *u8) i32; -extern fn XGetGeometry(display: *u8, drawable: i64, root_return: *i64, +@cabi extern fn XPending(display: *u8) i32; +@cabi extern fn XNextEvent(display: *u8, event: *u8) i32; +@cabi extern fn XGetGeometry(display: *u8, drawable: i64, root_return: *i64, x_return: *i32, y_return: *i32, width_return: *i32, height_return: *i32, border_width_return: *i32, depth_return: *i32) i32; -extern fn XFlush(display: *u8) i32; -extern fn XSync(display: *u8, discard: i32) i32; -extern fn XDestroyImage(image: *u8) i32; -extern fn XFreeGC(display: *u8, gc: *u8) i32; -extern fn XDestroyWindow(display: *u8, window: i64) i32; -extern fn XCloseDisplay(display: *u8) i32; +@cabi extern fn XFlush(display: *u8) i32; +@cabi extern fn XSync(display: *u8, discard: i32) i32; +@cabi extern fn XDestroyImage(image: *u8) i32; +@cabi extern fn XFreeGC(display: *u8, gc: *u8) i32; +@cabi extern fn XDestroyWindow(display: *u8, window: i64) i32; +@cabi extern fn XCloseDisplay(display: *u8) i32; enum LinuxX11Error { none, diff --git a/src/semantics/semantics.zag b/src/semantics/semantics.zag index 9fb6e85..8b80fd1 100644 --- a/src/semantics/semantics.zag +++ b/src/semantics/semantics.zag @@ -12,6 +12,7 @@ enum SemanticRole { toggle, checkbox, radio, + radio_group, slider, progress, text_field, @@ -21,6 +22,7 @@ enum SemanticRole { menu, menu_item, tab, + tab_list, list, list_item, table, @@ -72,6 +74,7 @@ struct SemanticsSpec { focus_order: i64, disabled: i32, selected: i32, + focused: i32, hidden: i32, live_region: LiveRegion, has_range: i32, @@ -110,6 +113,7 @@ struct SemanticsNode { focus_order: i64, disabled: i32, selected: i32, + focused: i32, hidden: i32, live_region: LiveRegion, has_range: i32, @@ -199,6 +203,7 @@ fn semantics_spec(id: NodeKey, parent: NodeKey, role: SemanticRole, name: []u8) .focus_order = 0, .disabled = 0, .selected = 0, + .focused = 0, .hidden = 0, .live_region = LiveRegion.off, .has_range = 0, @@ -292,6 +297,7 @@ fn semantic_node_evidence_hash(node: SemanticsNode) i64 { hash = semantic_evidence_mix(hash, node.focus_order); hash = semantic_evidence_mix(hash, node.disabled as i64); hash = semantic_evidence_mix(hash, node.selected as i64); + hash = semantic_evidence_mix(hash, node.focused as i64); hash = semantic_evidence_mix(hash, node.hidden as i64); hash = semantic_evidence_mix(hash, node.live_region as i64); hash = semantic_evidence_mix(hash, node.has_range as i64); @@ -334,6 +340,10 @@ fn semantics_find_index(tree: SemanticsTree, id: NodeKey) i32 { return 0 - 1; } +fn semantics_find_index_borrowed(tree: *SemanticsTree, id: NodeKey) i32 { + return semantics_find_index(tree.*, id); +} + fn semantics_fail(tree: @borrows_mut *SemanticsTree, error: SemanticsError, id: NodeKey) SemanticsError { tree.*.last_error = error; @@ -452,6 +462,7 @@ fn semantics_add(tree: @borrows_mut *SemanticsTree, .focus_order = spec.focus_order, .disabled = spec.disabled, .selected = spec.selected, + .focused = spec.focused, .hidden = spec.hidden, .live_region = spec.live_region, .has_range = spec.has_range, diff --git a/src/showcase/button_gallery.zag b/src/showcase/button_gallery.zag index 9af5a7a..7bf9d58 100644 --- a/src/showcase/button_gallery.zag +++ b/src/showcase/button_gallery.zag @@ -361,20 +361,20 @@ fn button_gallery_surface(list: *DisplayList, owner: NodeKey, bounds: Rect, elevation_token: SemanticElevationToken) i32 { let elevation: ResolvedElevationToken = semantic_elevation(elevation_token); let radius: i64 = semantic_radius(radius_token); - if (elevation.shadow_alpha > 0) { - let spread: i64 = elevation.shadow_spread; + if (elevation.ambient_shadow_alpha > 0) { + let spread: i64 = elevation.ambient_shadow_blur; if (button_gallery_push_round(list, owner, rect(bounds.x - spread, - bounds.y + elevation.shadow_offset_y - spread, + bounds.y + elevation.ambient_shadow_y - spread, bounds.width + spread * 2, bounds.height + spread * 2), radius + spread, semantic_color_with_alpha( - SemanticColorToken.shadow, elevation.shadow_alpha)) == 0) { + SemanticColorToken.shadow, elevation.ambient_shadow_alpha)) == 0) { return 0; } } if (button_gallery_push_round(list, owner, bounds, radius, semantic_color_with_alpha(SemanticColorToken.border_subtle, - elevation.border_alpha)) == 0) { return 0; } + elevation.edge_highlight_alpha)) == 0) { return 0; } let edge: i64 = unit_scale(); return button_gallery_push_round(list, owner, rect(bounds.x + edge, bounds.y + edge, diff --git a/src/showcase/component_state_gallery.zag b/src/showcase/component_state_gallery.zag new file mode 100644 index 0000000..ef860f1 --- /dev/null +++ b/src/showcase/component_state_gallery.zag @@ -0,0 +1,2572 @@ +@import("../components/surface.zag") +@import("../components/button.zag") +@import("../components/navigation_item.zag") +@import("../components/segmented_control.zag") +@import("../components/text.zag") +@import("../automation/talkback.zag") + +// Experimental reusable conformance artifact for the canonical component state +// language. Every fixture is emitted through its first-party component. Flex is +// the sole placement authority, Text owns every visible label, and the retained +// display, hit, semantics, and Talkback identities are deliberately inspectable. + +enum ComponentStateGallerySection { + surface_tiers, + surface_states, + button_states, + navigation_states, + segmented_control, +} + +enum ComponentStateGalleryError { + none, + invalid_spec, + invalid_runtime_state, + missing_font, + layout_error, + layout_overflow, + root_semantics_error, + surface_error, + button_error, + navigation_error, + segmented_error, + text_error, + text_contribution_error, + merge_error, + display_error, +} + +enum ComponentStateGalleryMergeError { + none, + invalid_source, + display_error, + hit_error, + semantics_error, +} + +enum ComponentStateGalleryEventKind { + pointer_enter, + pointer_leave, + pointer_down, + pointer_up_inside, + pointer_cancel, + focus_gained_pointer, + focus_gained_keyboard, + focus_lost, + activate_target, + segmented, +} + +struct ComponentStateGallerySpec { + id: NodeKey, + bounds: Rect, + name: []u8, + density: FlexDensity, + direction: LayoutDirection, +} + +struct ComponentStateGalleryRuntimeState { + hovered: NodeKey, + pressed: NodeKey, + focused: NodeKey, + focus_visible: i32, + segmented: SegmentedControlModel, + last_activated: NodeKey, + activation_count: i64, + revision: i64, +} + +struct ComponentStateGalleryEvent { + kind: ComponentStateGalleryEventKind, + target: NodeKey, + segmented_kind: SegmentedControlEventKind, + segmented_target_index: i32, +} + +struct ComponentStateGalleryTransition { + state: ComponentStateGalleryRuntimeState, + state_changed: i32, + focus_changed: i32, + activation_changed: i32, + selection_changed: i32, + rejected: i32, +} + +struct ComponentStateGalleryPlacement { + id: NodeKey, + caption_id: NodeKey, + cell_bounds: Rect, + caption_bounds: Rect, + control_bounds: Rect, +} + +struct ComponentStateGalleryLayout { + bounds: Rect, + title_bounds: Rect, + section_heading_bounds: ArrayList[Rect], + tier_placements: ArrayList[ComponentStateGalleryPlacement], + surface_placements: ArrayList[ComponentStateGalleryPlacement], + button_placements: ArrayList[ComponentStateGalleryPlacement], + navigation_placements: ArrayList[ComponentStateGalleryPlacement], + segmented_bounds: Rect, + required_height: i64, + minimum_width: i64, + flex_error: FlexError, + overflow: i32, +} + +struct ComponentStateGalleryCheckpoint { + display_length: i32, + display_revision: i64, + display_hash: i64, + display_last_error: DisplayError, + display_error_index: i32, + display_resource_length: i32, + display_resource_total_bytes: i64, + display_resource_revision: i64, + display_resource_hash: i64, + display_resource_last_error: RenderResourceError, + display_resource_error_id: i64, + hit_length: i32, + hit_revision: i64, + hit_last_error: HitTreeError, + hit_error_node: NodeKey, + semantics: SemanticsCheckpoint, + text_artifact_count: i32, + text_semantic_count: i32, + focus_snapshot_count: i32, + live_focus_id: NodeKey, + segmented: SegmentedControlArtifact, +} + +struct ComponentStateGalleryMergeResult { + error: ComponentStateGalleryMergeError, + display_error: DisplayError, + hit_error: HitTreeError, + semantics_error: SemanticsError, + display_start: i32, + display_end: i32, + hit_start: i32, + hit_end: i32, + semantics_start: i32, + semantics_end: i32, +} + +struct ComponentStateGalleryArtifact { + display: DisplayList, + hits: HitTree, + semantics: SemanticsTree, + layout: ComponentStateGalleryLayout, + tier_surfaces: ArrayList[SurfaceArtifact], + state_surfaces: ArrayList[SurfaceArtifact], + buttons: ArrayList[ButtonArtifact], + navigation_items: ArrayList[NavigationItemArtifact], + segmented: SegmentedControlArtifact, + id: NodeKey, + bounds: Rect, + density: FlexDensity, + direction: LayoutDirection, + text_artifact_count: i32, + text_semantic_count: i32, + read_only_surface_hit_count: i32, + focus_snapshot_count: i32, + live_focus_id: NodeKey, + runtime_state: ComponentStateGalleryRuntimeState, + runtime_hash: i64, + frozen_conformance: i32, + display_hash: i64, + hit_hash: i64, + talkback_hash: i64, + evidence_hash: i64, + error: ComponentStateGalleryError, + error_section: i32, + error_index: i32, + flex_error: FlexError, + surface_error: SurfaceBuildError, + button_error: ButtonBuildError, + navigation_error: NavigationItemBuildError, + segmented_error: SegmentedControlBuildError, + text_error: TextError, + text_contribution_error: TextContributionError, + merge_error: ComponentStateGalleryMergeError, + display_error: DisplayError, + hit_error: HitTreeError, + semantics_error: SemanticsError, +} + +fn component_state_gallery_section_count() i32 { return 5; } +fn component_state_gallery_tier_count() i32 { return 4; } +fn component_state_gallery_surface_state_count() i32 { return 8; } +fn component_state_gallery_button_state_count() i32 { return 8; } +fn component_state_gallery_navigation_state_count() i32 { return 6; } +fn component_state_gallery_segment_count() i32 { return 4; } +fn component_state_gallery_expected_text_count() i32 { return 61; } +fn component_state_gallery_expected_text_semantics() i32 { return 32; } +// Display operation count = 415. +// The elevation token struct separates ambient and contact shadow passes. +// Each shadowed surface emits one ambient + one contact rounded-rect op +// instead of a single shadow op. The 8-op delta from the previous 407 +// breaks down as follows: +// Tier surfaces (SurfaceMode.group, shadows drawn when pressed==0): +// panel tier: +1 contact shadow +// raised tier: +1 contact shadow +// overlay tier: +1 contact shadow +// base tier: +0 (all shadow alphas are zero) +// State surfaces (SurfaceMode.actionable, shadows drawn when +// effectively_enabled and pressed==0): +// rest: +1 hover: +1 focus: +1 pressed: +0 +// selected: +1 disabled: +0 loading: +0 error: +1 +// loading is effectively disabled (button_effectively_enabled==0 when +// loading==1), so it draws no shadow. pressed and disabled also +// suppress shadows. Total: 3 + 5 = 8 additional contact-shadow ops. +fn component_state_gallery_expected_display_operations() i32 { return 415; } +fn component_state_gallery_expected_display_resources() i32 { return 61; } +fn component_state_gallery_expected_hit_nodes() i32 { return 27; } +fn component_state_gallery_expected_semantic_nodes() i32 { return 64; } +fn component_state_gallery_max_root_id() i64 { return 1000000000; } + +fn component_state_gallery_spec(id: NodeKey, bounds: Rect) + ComponentStateGallerySpec { + return ComponentStateGallerySpec{ + .id = id, + .bounds = bounds, + .name = "Component state gallery", + .density = FlexDensity.standard, + .direction = LayoutDirection.ltr, + }; +} + +fn component_state_gallery_density_valid(density: FlexDensity) i32 { + let value: i64 = density as i64; + return (value >= FlexDensity.compact as i64 && + value <= FlexDensity.touch as i64) as i32; +} + +fn component_state_gallery_direction_valid(direction: LayoutDirection) i32 { + let value: i64 = direction as i64; + return (value >= LayoutDirection.ltr as i64 && + value <= LayoutDirection.rtl as i64) as i32; +} + +fn component_state_gallery_spec_valid( + spec: ComponentStateGallerySpec) i32 { + return (canvas_key_valid(spec.id) != 0 && + spec.id.value <= component_state_gallery_max_root_id() && + hit_geometry_valid(spec.bounds) != 0 && + spec.name.len > 0 && spec.name.len <= text_byte_limit() && + semantic_text_input_valid(spec.name) != 0 && + component_state_gallery_density_valid(spec.density) != 0 && + component_state_gallery_direction_valid(spec.direction) != 0) as i32; +} + +fn component_state_gallery_id(root: NodeKey, offset: i64) NodeKey { + return node_key_scoped(root.value * 10000 + offset, root.generation); +} + +fn component_state_gallery_title_id(root: NodeKey) NodeKey { + return component_state_gallery_id(root, 100); +} + +fn component_state_gallery_heading_id(root: NodeKey, index: i32) NodeKey { + return component_state_gallery_id(root, 200 + index as i64); +} + +fn component_state_gallery_tier_id(root: NodeKey, index: i32) NodeKey { + return component_state_gallery_id(root, 1000 + index as i64 * 10); +} + +fn component_state_gallery_tier_caption_id(root: NodeKey, index: i32) NodeKey { + return component_state_gallery_id(root, 1001 + index as i64 * 10); +} + +fn component_state_gallery_surface_id(root: NodeKey, index: i32) NodeKey { + return component_state_gallery_id(root, 2000 + index as i64 * 10); +} + +fn component_state_gallery_surface_caption_id(root: NodeKey, index: i32) + NodeKey { + return component_state_gallery_id(root, 2001 + index as i64 * 10); +} + +fn component_state_gallery_button_id(root: NodeKey, index: i32) NodeKey { + return component_state_gallery_id(root, 3000 + index as i64 * 10); +} + +fn component_state_gallery_button_caption_id(root: NodeKey, index: i32) + NodeKey { + return component_state_gallery_id(root, 3001 + index as i64 * 10); +} + +fn component_state_gallery_navigation_id(root: NodeKey, index: i32) NodeKey { + return component_state_gallery_id(root, 4000 + index as i64 * 10); +} + +fn component_state_gallery_navigation_caption_id(root: NodeKey, index: i32) + NodeKey { + return component_state_gallery_id(root, 4001 + index as i64 * 10); +} + +fn component_state_gallery_segmented_id(root: NodeKey) NodeKey { + return component_state_gallery_id(root, 5000); +} + +fn component_state_gallery_segment_id(root: NodeKey, index: i32) NodeKey { + return component_state_gallery_id(root, 5010 + index as i64); +} + +fn component_state_gallery_section_name(index: i32) []u8 { + return switch (index) { + 0 => "Surface elevation tiers", + 1 => "Actionable Surface states", + 2 => "Button states", + 3 => "NavigationItem states", + else => "SegmentedControl behavior", + }; +} + +fn component_state_gallery_tier(index: i32) SurfaceTier { + return switch (index) { + 1 => SurfaceTier.panel, + 2 => SurfaceTier.raised, + 3 => SurfaceTier.overlay, + else => SurfaceTier.base, + }; +} + +fn component_state_gallery_tier_name(index: i32) []u8 { + return switch (index) { + 1 => "PANEL", + 2 => "RAISED", + 3 => "OVERLAY", + else => "BASE", + }; +} + +fn component_state_gallery_tier_semantic_name(index: i32) []u8 { + return switch (index) { + 1 => "Panel Surface tier", + 2 => "Raised Surface tier", + 3 => "Overlay Surface tier", + else => "Base Surface tier", + }; +} + +fn component_state_gallery_state_name(index: i32) []u8 { + return switch (index) { + 1 => "HOVER", + 2 => "FOCUS", + 3 => "PRESSED", + 4 => "SELECTED", + 5 => "DISABLED", + 6 => "LOADING", + 7 => "ERROR", + else => "REST", + }; +} + +fn component_state_gallery_state_value(index: i32) []u8 { + return switch (index) { + 1 => "Hover fixture", + 2 => "Focus fixture", + 3 => "Pressed fixture", + 4 => "Selected", + 5 => "Disabled", + 6 => "Loading", + 7 => "Error", + else => "Rest fixture", + }; +} + +fn component_state_gallery_surface_state(index: i32) + SurfaceCanonicalState { + return switch (index) { + 1 => SurfaceCanonicalState.hover, + 2 => SurfaceCanonicalState.focus, + 3 => SurfaceCanonicalState.pressed, + 4 => SurfaceCanonicalState.selected, + 5 => SurfaceCanonicalState.disabled, + 6 => SurfaceCanonicalState.loading, + 7 => SurfaceCanonicalState.error, + else => SurfaceCanonicalState.rest, + }; +} + +fn component_state_gallery_button_state(index: i32) ButtonCanonicalState { + return switch (index) { + 1 => ButtonCanonicalState.hover, + 2 => ButtonCanonicalState.focus, + 3 => ButtonCanonicalState.pressed, + 4 => ButtonCanonicalState.selected, + 5 => ButtonCanonicalState.disabled, + 6 => ButtonCanonicalState.loading, + 7 => ButtonCanonicalState.error, + else => ButtonCanonicalState.rest, + }; +} + +fn component_state_gallery_navigation_state(index: i32) + ButtonCanonicalState { + return switch (index) { + 1 => ButtonCanonicalState.hover, + 2 => ButtonCanonicalState.focus, + 3 => ButtonCanonicalState.pressed, + 4 => ButtonCanonicalState.selected, + 5 => ButtonCanonicalState.disabled, + else => ButtonCanonicalState.rest, + }; +} + +fn component_state_gallery_state_description(kind: i32, index: i32) []u8 { + if (kind == 0) { + return switch (index) { + 1 => "Actionable Surface fixture: hover", + 2 => "Actionable Surface fixture: focus styling snapshot", + 3 => "Actionable Surface fixture: pressed", + 4 => "Actionable Surface fixture: selected", + 5 => "Actionable Surface fixture: disabled", + 6 => "Actionable Surface fixture: loading", + 7 => "Actionable Surface fixture: recoverable error", + else => "Actionable Surface fixture: rest", + }; + } + if (kind == 1) { + return switch (index) { + 1 => "Button fixture: hover", + 2 => "Button fixture: focus styling snapshot", + 3 => "Button fixture: pressed", + 4 => "Button fixture: selected", + 5 => "Button fixture: disabled", + 6 => "Button fixture: loading", + 7 => "Button fixture: recoverable error", + else => "Button fixture: rest", + }; + } + return switch (index) { + 1 => "NavigationItem fixture: hover", + 2 => "NavigationItem fixture: focus styling snapshot", + 3 => "NavigationItem fixture: pressed", + 4 => "NavigationItem fixture: selected", + 5 => "NavigationItem fixture: disabled", + else => "NavigationItem fixture: rest", + }; +} + +fn component_state_gallery_runtime_interaction( + base: ButtonInteraction, id: NodeKey, + runtime: ComponentStateGalleryRuntimeState) + ButtonInteraction { + let value: ButtonInteraction = base; + if (button_effectively_enabled(value) == 0) { return value; } + if (node_key_equal(runtime.hovered, id) != 0) { value.hovered = 1; } + if (node_key_equal(runtime.pressed, id) != 0) { value.pressed = 1; } + if (node_key_equal(runtime.focused, id) != 0) { + value.focused = 1; + value.focus_visible = runtime.focus_visible; + } + return value; +} + +fn component_state_gallery_segment_label(index: i32) []u8 { + return switch (index) { + 1 => "Focus", + 2 => "Disabled", + 3 => "Rest", + else => "Selected", + }; +} + +fn component_state_gallery_segment_description(index: i32) []u8 { + return switch (index) { + 1 => "Enabled keyboard focus target; focused only after a runtime event", + 2 => "Disabled segment; discoverable but unavailable", + 3 => "Enabled segment at rest", + else => "Selected segment with persistent marker", + }; +} + +fn component_state_gallery_title_height() i64 { return 48 * unit_scale(); } +fn component_state_gallery_heading_height() i64 { return 30 * unit_scale(); } +fn component_state_gallery_caption_height() i64 { return 20 * unit_scale(); } +fn component_state_gallery_tier_cell_height() i64 { return 116 * unit_scale(); } +fn component_state_gallery_surface_cell_height() i64 { return 116 * unit_scale(); } +fn component_state_gallery_button_cell_height() i64 { return 76 * unit_scale(); } +fn component_state_gallery_navigation_cell_height() i64 { return 76 * unit_scale(); } + +fn component_state_gallery_segmented_height(density: FlexDensity) i64 { + let height: i64 = 50 * unit_scale(); + let minimum: i64 = segmented_control_minimum_height(density); + if (minimum > height) { height = minimum; } + return height; +} + +fn component_state_gallery_minimum_width(density: FlexDensity) i64 { + let padding: i64 = flex_spacing(FlexSpacingToken.xxlarge, density) * 2; + let gaps: i64 = flex_spacing(FlexSpacingToken.large, density) * 3; + let content: i64 = 4 * 240 * unit_scale() + gaps; + return padding + content; +} + +fn component_state_gallery_section_height(index: i32, + density: FlexDensity) i64 { + let heading: i64 = component_state_gallery_heading_height(); + let section_gap: i64 = flex_spacing(FlexSpacingToken.medium, density); + let grid_gap: i64 = flex_spacing(FlexSpacingToken.large, density); + if (index == 0) { + return heading + section_gap + + component_state_gallery_tier_cell_height(); + } + if (index == 1) { + return heading + section_gap + + component_state_gallery_surface_cell_height() * 2 + grid_gap; + } + if (index == 2) { + return heading + section_gap + + component_state_gallery_button_cell_height() * 2 + grid_gap; + } + if (index == 3) { + return heading + section_gap + + component_state_gallery_navigation_cell_height() * 2 + grid_gap; + } + return heading + section_gap + + component_state_gallery_segmented_height(density); +} + +fn component_state_gallery_required_height(density: FlexDensity) i64 { + let total: i64 = flex_spacing(FlexSpacingToken.xxlarge, density) * 2 + + flex_spacing(FlexSpacingToken.xlarge, density) * 5 + + component_state_gallery_title_height(); + let index: i32 = 0; + while (index < component_state_gallery_section_count()) { + total = total + component_state_gallery_section_height(index, density); + index = index + 1; + } + return total; +} + +fn component_state_gallery_layout_empty(bounds: Rect, + density: FlexDensity) ComponentStateGalleryLayout { + return ComponentStateGalleryLayout{ + .bounds = bounds, + .title_bounds = rect(0, 0, 0, 0), + .section_heading_bounds = make[Rect]( + component_state_gallery_section_count()), + .tier_placements = make[ComponentStateGalleryPlacement]( + component_state_gallery_tier_count()), + .surface_placements = make[ComponentStateGalleryPlacement]( + component_state_gallery_surface_state_count()), + .button_placements = make[ComponentStateGalleryPlacement]( + component_state_gallery_button_state_count()), + .navigation_placements = make[ComponentStateGalleryPlacement]( + component_state_gallery_navigation_state_count()), + .segmented_bounds = rect(0, 0, 0, 0), + .required_height = component_state_gallery_required_height(density), + .minimum_width = component_state_gallery_minimum_width(density), + .flex_error = FlexError.none, + .overflow = 0, + }; +} + +fn component_state_gallery_layout_free( + layout: *ComponentStateGalleryLayout) void { + free[Rect](&layout.*.section_heading_bounds); + free[ComponentStateGalleryPlacement](&layout.*.tier_placements); + free[ComponentStateGalleryPlacement](&layout.*.surface_placements); + free[ComponentStateGalleryPlacement](&layout.*.button_placements); + free[ComponentStateGalleryPlacement](&layout.*.navigation_placements); +} + +fn component_state_gallery_absolute(parent: Rect, child: Rect) Rect { + return rect(parent.x + child.x, parent.y + child.y, + child.width, child.height); +} + +fn component_state_gallery_flex_ok(result: *FlexResult, + expected: i32) i32 { + return (result.*.error == FlexError.none && result.*.overflow == 0 && + result.*.duplicate_ids == 0 && + result.*.placements.len == expected) as i32; +} + +fn component_state_gallery_record_layout_error( + layout: *ComponentStateGalleryLayout, + result: *FlexResult) void { + layout.*.flex_error = result.*.error; + if (result.*.error == FlexError.none) { layout.*.overflow = 1; } +} + +fn component_state_gallery_placement(root: NodeKey, kind: i32, + index: i32, cell: Rect, density: FlexDensity, + direction: LayoutDirection, + layout: *ComponentStateGalleryLayout) + ComponentStateGalleryPlacement { + let id: NodeKey = component_state_gallery_tier_id(root, index); + let caption_id: NodeKey = component_state_gallery_tier_caption_id( + root, index); + if (kind == 1) { + id = component_state_gallery_surface_id(root, index); + caption_id = component_state_gallery_surface_caption_id(root, index); + } + if (kind == 2) { + id = component_state_gallery_button_id(root, index); + caption_id = component_state_gallery_button_caption_id(root, index); + } + if (kind == 3) { + id = component_state_gallery_navigation_id(root, index); + caption_id = component_state_gallery_navigation_caption_id(root, index); + } + let items: ArrayList[FlexItem] = make[FlexItem](2); + let caption: FlexItem = flex_item(1, + component_state_gallery_caption_height(), cell.width); + caption.min_main = component_state_gallery_caption_height(); + caption.max_main = component_state_gallery_caption_height(); + caption.shrink = 0; + let control: FlexItem = flex_item(2, 0, cell.width); + control.grow = 1; + control.shrink = 0; + control.min_main = 44 * unit_scale(); + push[FlexItem](&items, caption); + push[FlexItem](&items, control); + let style: FlexStyle = flex_style(FlexAxis.column, + flex_spacing(FlexSpacingToken.small, density), insets_all(0)); + style.direction = direction; + style.cross_alignment = CrossAlignment.stretch; + let result: FlexResult = flex_layout(items, + tight_constraints(cell.width, cell.height), style); + free[FlexItem](&items); + let placement: ComponentStateGalleryPlacement = + ComponentStateGalleryPlacement{ + .id = id, + .caption_id = caption_id, + .cell_bounds = cell, + .caption_bounds = rect(0, 0, 0, 0), + .control_bounds = rect(0, 0, 0, 0), + }; + if (component_state_gallery_flex_ok(&result, 2) == 0) { + component_state_gallery_record_layout_error(layout, &result); + flex_free(&result); + return placement; + } + placement.caption_bounds = component_state_gallery_absolute(cell, + result.placements.data[0].frame); + placement.control_bounds = component_state_gallery_absolute(cell, + result.placements.data[1].frame); + flex_free(&result); + return placement; +} + +fn component_state_gallery_fill_row( + layout: *ComponentStateGalleryLayout, + target: *ArrayList[ComponentStateGalleryPlacement], + root: NodeKey, kind: i32, start_index: i32, count: i32, + bounds: Rect, density: FlexDensity, direction: LayoutDirection) i32 { + let items: ArrayList[FlexItem] = make[FlexItem](count); + let index: i32 = 0; + while (index < count) { + let item: FlexItem = flex_item(1 + index as i64, 0, bounds.height); + item.grow = 1; + item.shrink = 1; + item.min_main = 240 * unit_scale(); + push[FlexItem](&items, item); + index = index + 1; + } + let style: FlexStyle = flex_style(FlexAxis.row, + flex_spacing(FlexSpacingToken.large, density), insets_all(0)); + style.direction = direction; + style.cross_alignment = CrossAlignment.stretch; + let result: FlexResult = flex_layout(items, + tight_constraints(bounds.width, bounds.height), style); + free[FlexItem](&items); + if (component_state_gallery_flex_ok(&result, count) == 0) { + component_state_gallery_record_layout_error(layout, &result); + flex_free(&result); + return 0; + } + index = 0; + while (index < count) { + let cell: Rect = component_state_gallery_absolute(bounds, + result.placements.data[index].frame); + let placement: ComponentStateGalleryPlacement = + component_state_gallery_placement(root, kind, + start_index + index, cell, density, direction, layout); + if (layout.*.flex_error != FlexError.none || + layout.*.overflow != 0) { + flex_free(&result); + return 0; + } + push[ComponentStateGalleryPlacement](target, placement); + index = index + 1; + } + flex_free(&result); + return 1; +} + +fn component_state_gallery_fill_grid( + layout: *ComponentStateGalleryLayout, + target: *ArrayList[ComponentStateGalleryPlacement], + root: NodeKey, kind: i32, columns: i32, bounds: Rect, + density: FlexDensity, direction: LayoutDirection) i32 { + let items: ArrayList[FlexItem] = make[FlexItem](2); + let first: FlexItem = flex_item(1, 0, bounds.width); + first.grow = 1; first.shrink = 1; first.min_main = 44 * unit_scale(); + let second: FlexItem = flex_item(2, 0, bounds.width); + second.grow = 1; second.shrink = 1; second.min_main = 44 * unit_scale(); + push[FlexItem](&items, first); push[FlexItem](&items, second); + let style: FlexStyle = flex_style(FlexAxis.column, + flex_spacing(FlexSpacingToken.large, density), insets_all(0)); + style.direction = direction; + style.cross_alignment = CrossAlignment.stretch; + let rows: FlexResult = flex_layout(items, + tight_constraints(bounds.width, bounds.height), style); + free[FlexItem](&items); + if (component_state_gallery_flex_ok(&rows, 2) == 0) { + component_state_gallery_record_layout_error(layout, &rows); + flex_free(&rows); + return 0; + } + let row0: Rect = component_state_gallery_absolute(bounds, + rows.placements.data[0].frame); + let row1: Rect = component_state_gallery_absolute(bounds, + rows.placements.data[1].frame); + let ok0: i32 = component_state_gallery_fill_row(layout, target, + root, kind, 0, columns, row0, density, direction); + let ok1: i32 = 0; + if (ok0 != 0) { + ok1 = component_state_gallery_fill_row(layout, target, + root, kind, columns, columns, row1, density, direction); + } + flex_free(&rows); + return (ok0 != 0 && ok1 != 0) as i32; +} + +fn component_state_gallery_section_content( + layout: *ComponentStateGalleryLayout, + section: Rect, density: FlexDensity, direction: LayoutDirection, + heading_index: i32) Rect { + let items: ArrayList[FlexItem] = make[FlexItem](2); + let heading: FlexItem = flex_item(1, + component_state_gallery_heading_height(), section.width); + heading.min_main = component_state_gallery_heading_height(); + heading.max_main = component_state_gallery_heading_height(); + heading.shrink = 0; + let content: FlexItem = flex_item(2, 0, section.width); + content.grow = 1; content.shrink = 0; content.min_main = unit_scale(); + push[FlexItem](&items, heading); push[FlexItem](&items, content); + let style: FlexStyle = flex_style(FlexAxis.column, + flex_spacing(FlexSpacingToken.medium, density), insets_all(0)); + style.direction = direction; + style.cross_alignment = CrossAlignment.stretch; + let result: FlexResult = flex_layout(items, + tight_constraints(section.width, section.height), style); + free[FlexItem](&items); + if (component_state_gallery_flex_ok(&result, 2) == 0) { + component_state_gallery_record_layout_error(layout, &result); + flex_free(&result); + return rect(0, 0, 0, 0); + } + push[Rect](&layout.*.section_heading_bounds, + component_state_gallery_absolute(section, + result.placements.data[0].frame)); + let output: Rect = component_state_gallery_absolute(section, + result.placements.data[1].frame); + flex_free(&result); + return output; +} + +fn component_state_gallery_layout(spec: ComponentStateGallerySpec) + ComponentStateGalleryLayout { + let layout: ComponentStateGalleryLayout = + component_state_gallery_layout_empty(spec.bounds, spec.density); + if (spec.bounds.width < layout.minimum_width || + spec.bounds.height < layout.required_height) { + layout.overflow = 1; + return layout; + } + let items: ArrayList[FlexItem] = make[FlexItem](6); + let title: FlexItem = flex_item(1, + component_state_gallery_title_height(), spec.bounds.width); + title.min_main = component_state_gallery_title_height(); + title.max_main = component_state_gallery_title_height(); + title.shrink = 0; + push[FlexItem](&items, title); + let section_index: i32 = 0; + while (section_index < component_state_gallery_section_count()) { + let height: i64 = component_state_gallery_section_height( + section_index, spec.density); + let section_item: FlexItem = flex_item(2 + section_index as i64, + height, spec.bounds.width); + section_item.min_main = height; + section_item.max_main = height; + section_item.shrink = 0; + push[FlexItem](&items, section_item); + section_index = section_index + 1; + } + let style: FlexStyle = flex_style(FlexAxis.column, + flex_spacing(FlexSpacingToken.xlarge, spec.density), + insets_all(flex_spacing(FlexSpacingToken.xxlarge, spec.density))); + style.direction = spec.direction; + style.cross_alignment = CrossAlignment.stretch; + let outer: FlexResult = flex_layout(items, + tight_constraints(spec.bounds.width, spec.bounds.height), style); + free[FlexItem](&items); + if (component_state_gallery_flex_ok(&outer, 6) == 0) { + component_state_gallery_record_layout_error(&layout, &outer); + flex_free(&outer); + return layout; + } + layout.title_bounds = component_state_gallery_absolute(spec.bounds, + outer.placements.data[0].frame); + + let tier_section: Rect = component_state_gallery_absolute(spec.bounds, + outer.placements.data[1].frame); + let tier_content: Rect = component_state_gallery_section_content(&layout, + tier_section, spec.density, spec.direction, 0); + if (layout.overflow == 0 && layout.flex_error == FlexError.none) { + _ = component_state_gallery_fill_row(&layout, + &layout.tier_placements, spec.id, 0, 0, + component_state_gallery_tier_count(), tier_content, + spec.density, spec.direction); + } + + let surface_section: Rect = component_state_gallery_absolute(spec.bounds, + outer.placements.data[2].frame); + let surface_content: Rect = component_state_gallery_section_content(&layout, + surface_section, spec.density, spec.direction, 1); + if (layout.overflow == 0 && layout.flex_error == FlexError.none) { + _ = component_state_gallery_fill_grid(&layout, + &layout.surface_placements, spec.id, 1, 4, surface_content, + spec.density, spec.direction); + } + + let button_section: Rect = component_state_gallery_absolute(spec.bounds, + outer.placements.data[3].frame); + let button_content: Rect = component_state_gallery_section_content(&layout, + button_section, spec.density, spec.direction, 2); + if (layout.overflow == 0 && layout.flex_error == FlexError.none) { + _ = component_state_gallery_fill_grid(&layout, + &layout.button_placements, spec.id, 2, 4, button_content, + spec.density, spec.direction); + } + + let navigation_section: Rect = component_state_gallery_absolute(spec.bounds, + outer.placements.data[4].frame); + let navigation_content: Rect = component_state_gallery_section_content( + &layout, navigation_section, spec.density, spec.direction, 3); + if (layout.overflow == 0 && layout.flex_error == FlexError.none) { + _ = component_state_gallery_fill_grid(&layout, + &layout.navigation_placements, spec.id, 3, 3, + navigation_content, spec.density, spec.direction); + } + + let segmented_section: Rect = component_state_gallery_absolute(spec.bounds, + outer.placements.data[5].frame); + let segmented_content: Rect = component_state_gallery_section_content( + &layout, segmented_section, spec.density, spec.direction, 4); + if (layout.overflow == 0 && layout.flex_error == FlexError.none) { + let segment_items: ArrayList[FlexItem] = make[FlexItem](1); + let segment_item: FlexItem = flex_item(1, 720 * unit_scale(), + segmented_content.height); + segment_item.min_main = segmented_control_minimum_width( + segmented_control_model(component_state_gallery_segment_count(), 0), + spec.density); + segment_item.max_main = 720 * unit_scale(); + segment_item.shrink = 1; + push[FlexItem](&segment_items, segment_item); + let segment_style: FlexStyle = flex_style(FlexAxis.row, 0, + insets_all(0)); + segment_style.direction = spec.direction; + segment_style.main_alignment = MainAlignment.center; + segment_style.cross_alignment = CrossAlignment.stretch; + let segment_result: FlexResult = flex_layout(segment_items, + tight_constraints(segmented_content.width, + segmented_content.height), segment_style); + free[FlexItem](&segment_items); + if (component_state_gallery_flex_ok(&segment_result, 1) == 0) { + component_state_gallery_record_layout_error(&layout, + &segment_result); + } else { + layout.segmented_bounds = component_state_gallery_absolute( + segmented_content, segment_result.placements.data[0].frame); + } + flex_free(&segment_result); + } + flex_free(&outer); + return layout; +} + +fn component_state_gallery_empty_segmented(root: NodeKey) + SegmentedControlArtifact { + let model: SegmentedControlModel = segmented_control_model( + component_state_gallery_segment_count(), 0); + let spec: SegmentedControlSpec = segmented_control_spec( + component_state_gallery_segmented_id(root), semantic_root_key(), + rect(0, 0, 720 * unit_scale(), 50 * unit_scale()), + "Component state segments", model); + return segmented_control_empty_artifact(spec); +} + +fn component_state_gallery_empty( + spec: ComponentStateGallerySpec) + ComponentStateGalleryArtifact { + return ComponentStateGalleryArtifact{ + .display = display_list_make(), + .hits = hit_tree_make(), + .semantics = semantics_tree_make(), + .layout = component_state_gallery_layout_empty( + spec.bounds, spec.density), + .tier_surfaces = make[SurfaceArtifact]( + component_state_gallery_tier_count()), + .state_surfaces = make[SurfaceArtifact]( + component_state_gallery_surface_state_count()), + .buttons = make[ButtonArtifact]( + component_state_gallery_button_state_count()), + .navigation_items = make[NavigationItemArtifact]( + component_state_gallery_navigation_state_count()), + .segmented = component_state_gallery_empty_segmented(spec.id), + .id = spec.id, + .bounds = spec.bounds, + .density = spec.density, + .direction = spec.direction, + .text_artifact_count = 0, + .text_semantic_count = 0, + .read_only_surface_hit_count = 0, + .focus_snapshot_count = 0, + .live_focus_id = semantic_none_key(), + .runtime_state = component_state_gallery_runtime_state_default(), + .runtime_hash = 0, + .frozen_conformance = 0, + .display_hash = 0, + .hit_hash = 0, + .talkback_hash = 0, + .evidence_hash = 0, + .error = ComponentStateGalleryError.none, + .error_section = 0 - 1, + .error_index = 0 - 1, + .flex_error = FlexError.none, + .surface_error = SurfaceBuildError.none, + .button_error = ButtonBuildError.none, + .navigation_error = NavigationItemBuildError.none, + .segmented_error = SegmentedControlBuildError.none, + .text_error = TextError.none, + .text_contribution_error = TextContributionError.none, + .merge_error = ComponentStateGalleryMergeError.none, + .display_error = DisplayError.none, + .hit_error = HitTreeError.none, + .semantics_error = SemanticsError.none, + }; +} + +fn component_state_gallery_checkpoint( + artifact: *ComponentStateGalleryArtifact) + ComponentStateGalleryCheckpoint { + return ComponentStateGalleryCheckpoint{ + .display_length = artifact.*.display.ops.len, + .display_revision = artifact.*.display.revision, + .display_hash = artifact.*.display.content_hash, + .display_last_error = artifact.*.display.last_error, + .display_error_index = artifact.*.display.error_index, + .display_resource_length = + artifact.*.display.resources.resources.len, + .display_resource_total_bytes = + artifact.*.display.resources.total_bytes, + .display_resource_revision = artifact.*.display.resources.revision, + .display_resource_hash = artifact.*.display.resources.content_hash, + .display_resource_last_error = + artifact.*.display.resources.last_error, + .display_resource_error_id = artifact.*.display.resources.error_id, + .hit_length = artifact.*.hits.nodes.len, + .hit_revision = artifact.*.hits.revision, + .hit_last_error = artifact.*.hits.last_error, + .hit_error_node = artifact.*.hits.error_node, + .semantics = semantics_tree_checkpoint(&artifact.*.semantics), + .text_artifact_count = artifact.*.text_artifact_count, + .text_semantic_count = artifact.*.text_semantic_count, + .focus_snapshot_count = artifact.*.focus_snapshot_count, + .live_focus_id = artifact.*.live_focus_id, + .segmented = artifact.*.segmented, + }; +} + +// Component scratch emissions add no resources. The generic checkpoint +// restores component display operations plus hit/semantic state. Multi-Text +// transactions additionally record their exact hashed resource IDs and use +// component_state_gallery_rollback_text_resources below. +fn component_state_gallery_rollback( + artifact: *ComponentStateGalleryArtifact, + checkpoint: ComponentStateGalleryCheckpoint) void { + artifact.*.display.ops.len = checkpoint.display_length; + artifact.*.display.revision = checkpoint.display_revision; + artifact.*.display.content_hash = checkpoint.display_hash; + artifact.*.display.last_error = checkpoint.display_last_error; + artifact.*.display.error_index = checkpoint.display_error_index; + artifact.*.hits.nodes.len = checkpoint.hit_length; + artifact.*.hits.revision = checkpoint.hit_revision; + artifact.*.hits.last_error = checkpoint.hit_last_error; + artifact.*.hits.error_node = checkpoint.hit_error_node; + _ = semantics_tree_rollback(&artifact.*.semantics, + checkpoint.semantics); + artifact.*.text_artifact_count = checkpoint.text_artifact_count; + artifact.*.text_semantic_count = checkpoint.text_semantic_count; + artifact.*.focus_snapshot_count = checkpoint.focus_snapshot_count; + artifact.*.live_focus_id = checkpoint.live_focus_id; + artifact.*.segmented = checkpoint.segmented; +} + +fn component_state_gallery_remove_resource( + store: *RenderResourceStore, id: i64) i32 { + let found: i32 = 0 - 1; + let index: i32 = 0; + while (index < store.*.resources.len) { + if (store.*.resources.data[index].id == id) { + found = index; + break; + } + index = index + 1; + } + if (found < 0) { return 0; } + free[u8](&store.*.resources.data[found].payload); + index = found; + while (index + 1 < store.*.resources.len) { + store.*.resources.data[index] = store.*.resources.data[index + 1]; + index = index + 1; + } + store.*.resources.len = store.*.resources.len - 1; + return 1; +} + +fn component_state_gallery_rollback_text_resources( + artifact: *ComponentStateGalleryArtifact, + checkpoint: ComponentStateGalleryCheckpoint, + resource_ids: *ArrayList[i64]) i32 { + let exact: i32 = 1; + let index: i32 = resource_ids.*.len - 1; + while (index >= 0) { + if (component_state_gallery_remove_resource( + &artifact.*.display.resources, + resource_ids.*.data[index]) == 0) { exact = 0; } + index = index - 1; + } + if (artifact.*.display.resources.resources.len != + checkpoint.display_resource_length) { exact = 0; } + artifact.*.display.resources.total_bytes = + checkpoint.display_resource_total_bytes; + artifact.*.display.resources.revision = + checkpoint.display_resource_revision; + artifact.*.display.resources.content_hash = + checkpoint.display_resource_hash; + artifact.*.display.resources.last_error = + checkpoint.display_resource_last_error; + artifact.*.display.resources.error_id = + checkpoint.display_resource_error_id; + component_state_gallery_rollback(artifact, checkpoint); + return exact; +} + +fn component_state_gallery_merge_result() + ComponentStateGalleryMergeResult { + return ComponentStateGalleryMergeResult{ + .error = ComponentStateGalleryMergeError.none, + .display_error = DisplayError.none, + .hit_error = HitTreeError.none, + .semantics_error = SemanticsError.none, + .display_start = 0 - 1, + .display_end = 0 - 1, + .hit_start = 0 - 1, + .hit_end = 0 - 1, + .semantics_start = 0 - 1, + .semantics_end = 0 - 1, + }; +} + +fn component_state_gallery_semantic_spec( + node: @retained_by_return SemanticsNode, + parent: NodeKey, fallback_value: @retained_by_return []u8, + clear_focus: i32) SemanticsSpec { + let spec: SemanticsSpec = SemanticsSpec{ + .id = node.id, + .parent = parent, + .role = node.role, + .name = node.name.data[0..node.name.len], + .description = node.description.data[0..node.description.len], + .value = node.value.data[0..node.value.len], + .action_mask = node.action_mask, + .focus_order = node.focus_order, + .disabled = node.disabled, + .selected = node.selected, + .focused = node.focused, + .hidden = node.hidden, + .live_region = node.live_region, + .has_range = node.has_range, + .range_min = node.range_min, + .range_max = node.range_max, + .range_value = node.range_value, + .range_step = node.range_step, + .text_length = node.text_length, + .selection_start = node.selection_start, + .selection_end = node.selection_end, + .has_bounds = node.has_bounds, + .bounds = node.bounds, + .labelled_by = node.labelled_by, + .described_by = node.described_by, + .controls = node.controls, + .row_count = node.row_count, + .column_count = node.column_count, + .row_index = node.row_index, + .column_index = node.column_index, + .row_span = node.row_span, + .column_span = node.column_span, + .level = node.level, + .set_size = node.set_size, + .position_in_set = node.position_in_set, + .expanded = node.expanded, + }; + if (spec.value.len == 0 && fallback_value.len > 0) { + spec.value = fallback_value; + } + if (clear_focus != 0) { spec.focused = 0; } + return spec; +} + +fn component_state_gallery_merge_scratch( + artifact: *ComponentStateGalleryArtifact, + scratch_display: *DisplayList, + scratch_hits: *HitTree, + scratch_semantics: *SemanticsTree, + publish_hits: i32, semantic_parent: NodeKey, + clear_focus_id: NodeKey, fallback_value: []u8, + expected_hits: i32, expected_semantics: i32) + ComponentStateGalleryMergeResult { + let result: ComponentStateGalleryMergeResult = + component_state_gallery_merge_result(); + if ((publish_hits != 0 && publish_hits != 1) || + scratch_display.*.sealed != 0 || + scratch_display.*.resources.resources.len != 0 || + scratch_display.*.last_error != DisplayError.none || + scratch_hits.*.nodes.len != expected_hits || + scratch_hits.*.last_error != HitTreeError.none || + scratch_semantics.*.nodes.len != expected_semantics || + scratch_semantics.*.last_error != SemanticsError.none || + artifact.*.display.sealed != 0 || + semantics_find_index_borrowed(&artifact.*.semantics, + semantic_parent) < 0) { + result.error = ComponentStateGalleryMergeError.invalid_source; + return result; + } + let checkpoint: ComponentStateGalleryCheckpoint = + component_state_gallery_checkpoint(artifact); + result.display_start = artifact.*.display.ops.len; + let index: i32 = 0; + while (index < scratch_display.*.ops.len) { + let error: DisplayError = display_list_push(&artifact.*.display, + scratch_display.*.ops.data[index]); + if (error != DisplayError.none) { + component_state_gallery_rollback(artifact, checkpoint); + result.error = ComponentStateGalleryMergeError.display_error; + result.display_error = error; + return result; + } + index = index + 1; + } + result.display_end = artifact.*.display.ops.len; + + result.hit_start = artifact.*.hits.nodes.len; + if (publish_hits != 0) { + index = 0; + while (index < scratch_hits.*.nodes.len) { + let error: HitTreeError = hit_tree_add(&artifact.*.hits, + scratch_hits.*.nodes.data[index]); + if (error != HitTreeError.none) { + component_state_gallery_rollback(artifact, checkpoint); + result.error = ComponentStateGalleryMergeError.hit_error; + result.hit_error = error; + return result; + } + index = index + 1; + } + } + result.hit_end = artifact.*.hits.nodes.len; + + result.semantics_start = artifact.*.semantics.nodes.len; + index = 0; + while (index < scratch_semantics.*.nodes.len) { + let source: SemanticsNode = scratch_semantics.*.nodes.data[index]; + let parent: NodeKey = source.parent; + if (node_key_equal(parent, semantic_root_key()) != 0) { + parent = semantic_parent; + } + let clear_focus: i32 = node_key_equal(source.id, + clear_focus_id); + let value: []u8 = ""; + if (scratch_semantics.*.nodes.len == 1) { + value = fallback_value; + } + let spec: SemanticsSpec = component_state_gallery_semantic_spec( + source, parent, value, clear_focus); + let error: SemanticsError = semantics_add( + &artifact.*.semantics, spec); + if (error != SemanticsError.none) { + component_state_gallery_rollback(artifact, checkpoint); + result.error = ComponentStateGalleryMergeError.semantics_error; + result.semantics_error = error; + return result; + } + index = index + 1; + } + result.semantics_end = artifact.*.semantics.nodes.len; + return result; +} + +fn component_state_gallery_record_merge_error( + artifact: *ComponentStateGalleryArtifact, + result: ComponentStateGalleryMergeResult, + section: i32, index: i32) void { + artifact.*.error = ComponentStateGalleryError.merge_error; + artifact.*.error_section = section; + artifact.*.error_index = index; + artifact.*.merge_error = result.error; + artifact.*.display_error = result.display_error; + artifact.*.hit_error = result.hit_error; + artifact.*.semantics_error = result.semantics_error; +} + +fn component_state_gallery_root_semantics( + artifact: *ComponentStateGalleryArtifact, + spec: ComponentStateGallerySpec) i32 { + let root: SemanticsSpec = semantics_spec(spec.id, semantic_root_key(), + SemanticRole.group, spec.name); + root.description = + "Experimental canonical component state and elevation conformance gallery"; + root.value = + "Read-only tier fixtures and explicit interactive component specimens"; + root.has_bounds = 1; + root.bounds = spec.bounds; + let error: SemanticsError = semantics_add(&artifact.*.semantics, root); + if (error == SemanticsError.none) { return 1; } + artifact.*.error = ComponentStateGalleryError.root_semantics_error; + artifact.*.semantics_error = error; + return 0; +} + +fn component_state_gallery_emit_text( + artifact: *ComponentStateGalleryArtifact, + face: *OpenTypeFace, id: NodeKey, parent: NodeKey, + bounds: Rect, value: []u8, type_token: SemanticTypeToken, + color_token: SemanticColorToken, own_semantics: i32, + disabled: i32, section: i32, index: i32) i32 { + let spec: TextSpec = text_spec(id, parent, bounds, value, + type_token, color_token); + if (own_semantics == 0) { + spec.semantics_mode = TextSemanticsMode.parent_owned; + } + spec.disabled = disabled; + let text: TextArtifact = text_build(face.*, spec); + if (text.error != TextError.none) { + artifact.*.error = ComponentStateGalleryError.text_error; + artifact.*.error_section = section; + artifact.*.error_index = index; + artifact.*.text_error = text.error; + text_free(&text); + return 0; + } + let contribution: TextContributionResult = text_contribute(&text, + &artifact.*.display, &artifact.*.semantics); + if (contribution.error != TextContributionError.none || + contribution.display_operations_added != 5 || + contribution.display_resources_added != 1 || + contribution.semantic_nodes_added != own_semantics) { + artifact.*.error = ComponentStateGalleryError.text_contribution_error; + artifact.*.error_section = section; + artifact.*.error_index = index; + artifact.*.text_contribution_error = contribution.error; + artifact.*.display_error = contribution.display_error; + artifact.*.semantics_error = contribution.semantics_error; + text_free(&text); + return 0; + } + artifact.*.text_artifact_count = artifact.*.text_artifact_count + 1; + artifact.*.text_semantic_count = artifact.*.text_semantic_count + + own_semantics; + text_free(&text); + return 1; +} + +fn component_state_gallery_emit_tier_surface( + artifact: *ComponentStateGalleryArtifact, + face: *OpenTypeFace, + placement: ComponentStateGalleryPlacement, index: i32) i32 { + let spec: SurfaceSpec = surface_spec(placement.id, + semantic_root_key(), placement.control_bounds, + component_state_gallery_tier_semantic_name(index), + component_state_gallery_tier(index)); + spec.description = "Read-only Surface elevation tier fixture"; + spec.density = artifact.*.density; + let scratch_display: DisplayList = display_list_make(); + let scratch_hits: HitTree = hit_tree_make(); + let scratch_semantics: SemanticsTree = semantics_tree_make(); + let built: SurfaceBuildResult = surface_emit(spec, + &scratch_display, &scratch_hits, &scratch_semantics); + if (built.error != SurfaceBuildError.none) { + artifact.*.error = ComponentStateGalleryError.surface_error; + artifact.*.error_section = 0; + artifact.*.error_index = index; + artifact.*.surface_error = built.error; + artifact.*.display_error = built.display_error; + artifact.*.hit_error = built.hit_error; + artifact.*.semantics_error = built.semantics_error; + semantics_tree_free(&scratch_semantics); + hit_tree_free(&scratch_hits); + display_list_free(&scratch_display); + return 0; + } + let checkpoint: ComponentStateGalleryCheckpoint = + component_state_gallery_checkpoint(artifact); + let merged: ComponentStateGalleryMergeResult = + component_state_gallery_merge_scratch(artifact, + &scratch_display, &scratch_hits, &scratch_semantics, + 0, artifact.*.id, semantic_none_key(), "", 1, 1); + semantics_tree_free(&scratch_semantics); + hit_tree_free(&scratch_hits); + display_list_free(&scratch_display); + if (merged.error != ComponentStateGalleryMergeError.none) { + component_state_gallery_record_merge_error(artifact, merged, 0, index); + return 0; + } + let surface: SurfaceArtifact = built.artifact; + surface.display_op_start = merged.display_start; + surface.display_op_end = merged.display_end; + surface.hit_node_index = 0 - 1; + surface.semantics_node_index = merged.semantics_start; + if (component_state_gallery_emit_text(artifact, face, placement.id, + placement.id, surface.content_bounds, "Content surface", + SemanticTypeToken.label, surface.style.content_color_token, + 0, 0, 0, index) == 0) { + component_state_gallery_rollback(artifact, checkpoint); + return 0; + } + push[SurfaceArtifact](&artifact.*.tier_surfaces, surface); + return 1; +} + +fn component_state_gallery_emit_state_surface( + artifact: *ComponentStateGalleryArtifact, + face: *OpenTypeFace, + placement: ComponentStateGalleryPlacement, index: i32) i32 { + let spec: SurfaceSpec = surface_action_spec(placement.id, + semantic_root_key(), placement.control_bounds, "Action card", + SurfaceTier.panel); + spec.description = component_state_gallery_state_description(0, index); + spec.focus_order = 100 + index as i64; + spec.z_order = 100 + index as i64; + spec.density = artifact.*.density; + spec.interaction = surface_canonical_state( + component_state_gallery_surface_state(index)); + spec.interaction = component_state_gallery_runtime_interaction( + spec.interaction, placement.id, artifact.*.runtime_state); + let scratch_display: DisplayList = display_list_make(); + let scratch_hits: HitTree = hit_tree_make(); + let scratch_semantics: SemanticsTree = semantics_tree_make(); + let built: SurfaceBuildResult = surface_emit(spec, + &scratch_display, &scratch_hits, &scratch_semantics); + if (built.error != SurfaceBuildError.none) { + artifact.*.error = ComponentStateGalleryError.surface_error; + artifact.*.error_section = 1; + artifact.*.error_index = index; + artifact.*.surface_error = built.error; + artifact.*.display_error = built.display_error; + artifact.*.hit_error = built.hit_error; + artifact.*.semantics_error = built.semantics_error; + semantics_tree_free(&scratch_semantics); + hit_tree_free(&scratch_hits); + display_list_free(&scratch_display); + return 0; + } + let checkpoint: ComponentStateGalleryCheckpoint = + component_state_gallery_checkpoint(artifact); + let clear_focus: NodeKey = semantic_none_key(); + if (index == 2 && node_key_equal(artifact.*.runtime_state.focused, + placement.id) == 0) { clear_focus = placement.id; } + let merged: ComponentStateGalleryMergeResult = + component_state_gallery_merge_scratch(artifact, + &scratch_display, &scratch_hits, &scratch_semantics, + 1, artifact.*.id, clear_focus, + component_state_gallery_state_value(index), 1, 1); + semantics_tree_free(&scratch_semantics); + hit_tree_free(&scratch_hits); + display_list_free(&scratch_display); + if (merged.error != ComponentStateGalleryMergeError.none) { + component_state_gallery_record_merge_error(artifact, merged, 1, index); + return 0; + } + let surface: SurfaceArtifact = built.artifact; + surface.display_op_start = merged.display_start; + surface.display_op_end = merged.display_end; + surface.hit_node_index = merged.hit_start; + surface.semantics_node_index = merged.semantics_start; + if (component_state_gallery_emit_text(artifact, face, placement.id, + placement.id, surface.content_bounds, "Action card", + SemanticTypeToken.label, surface.style.content_color_token, + 0, (surface.effectively_enabled == 0) as i32, 1, index) == 0) { + component_state_gallery_rollback(artifact, checkpoint); + return 0; + } + if (index == 2) { + artifact.*.focus_snapshot_count = + artifact.*.focus_snapshot_count + 1; + } + push[SurfaceArtifact](&artifact.*.state_surfaces, surface); + return 1; +} + +fn component_state_gallery_emit_button( + artifact: *ComponentStateGalleryArtifact, + face: *OpenTypeFace, + placement: ComponentStateGalleryPlacement, index: i32) i32 { + let spec: ButtonSpec = button_spec(placement.id, semantic_root_key(), + placement.control_bounds, "Run build"); + spec.description = component_state_gallery_state_description(1, index); + spec.focus_order = 200 + index as i64; + spec.z_order = 200 + index as i64; + spec.density = artifact.*.density; + spec.variant = ButtonVariant.secondary; + spec.interaction = button_canonical_state( + component_state_gallery_button_state(index)); + spec.interaction = component_state_gallery_runtime_interaction( + spec.interaction, placement.id, artifact.*.runtime_state); + let scratch_display: DisplayList = display_list_make(); + let scratch_hits: HitTree = hit_tree_make(); + let scratch_semantics: SemanticsTree = semantics_tree_make(); + let built: ButtonBuildResult = button_emit(spec, + &scratch_display, &scratch_hits, &scratch_semantics); + if (built.error != ButtonBuildError.none) { + artifact.*.error = ComponentStateGalleryError.button_error; + artifact.*.error_section = 2; + artifact.*.error_index = index; + artifact.*.button_error = built.error; + artifact.*.display_error = built.display_error; + artifact.*.hit_error = built.hit_error; + artifact.*.semantics_error = built.semantics_error; + semantics_tree_free(&scratch_semantics); + hit_tree_free(&scratch_hits); + display_list_free(&scratch_display); + return 0; + } + let checkpoint: ComponentStateGalleryCheckpoint = + component_state_gallery_checkpoint(artifact); + let clear_focus: NodeKey = semantic_none_key(); + if (index == 2 && node_key_equal(artifact.*.runtime_state.focused, + placement.id) == 0) { clear_focus = placement.id; } + let merged: ComponentStateGalleryMergeResult = + component_state_gallery_merge_scratch(artifact, + &scratch_display, &scratch_hits, &scratch_semantics, + 1, artifact.*.id, clear_focus, + component_state_gallery_state_value(index), 1, 1); + semantics_tree_free(&scratch_semantics); + hit_tree_free(&scratch_hits); + display_list_free(&scratch_display); + if (merged.error != ComponentStateGalleryMergeError.none) { + component_state_gallery_record_merge_error(artifact, merged, 2, index); + return 0; + } + let button: ButtonArtifact = built.artifact; + button.display_op_start = merged.display_start; + button.display_op_end = merged.display_end; + button.hit_node_index = merged.hit_start; + button.semantics_node_index = merged.semantics_start; + if (button.content_visible != 0 && + component_state_gallery_emit_text(artifact, face, placement.id, + placement.id, button.content_bounds, "Run build", + SemanticTypeToken.label, button.style.text_token, + 0, (button.effectively_enabled == 0) as i32, + 2, index) == 0) { + component_state_gallery_rollback(artifact, checkpoint); + return 0; + } + if (index == 2) { + artifact.*.focus_snapshot_count = + artifact.*.focus_snapshot_count + 1; + } + push[ButtonArtifact](&artifact.*.buttons, button); + return 1; +} + +fn component_state_gallery_emit_navigation( + artifact: *ComponentStateGalleryArtifact, + face: *OpenTypeFace, + placement: ComponentStateGalleryPlacement, index: i32) i32 { + let spec: NavigationItemSpec = navigation_item_spec(placement.id, + semantic_root_key(), placement.control_bounds, "Project"); + spec.description = component_state_gallery_state_description(2, index); + spec.focus_order = 300 + index as i64; + spec.z_order = 300 + index as i64; + spec.set_size = component_state_gallery_navigation_state_count() as i64; + spec.position_in_set = 1 + index as i64; + spec.density = artifact.*.density; + spec.interaction = navigation_item_canonical_state( + component_state_gallery_navigation_state(index)); + spec.interaction = component_state_gallery_runtime_interaction( + spec.interaction, placement.id, artifact.*.runtime_state); + let scratch_display: DisplayList = display_list_make(); + let scratch_hits: HitTree = hit_tree_make(); + let scratch_semantics: SemanticsTree = semantics_tree_make(); + let built: NavigationItemBuildResult = navigation_item_emit(spec, + &scratch_display, &scratch_hits, &scratch_semantics); + if (built.error != NavigationItemBuildError.none) { + artifact.*.error = ComponentStateGalleryError.navigation_error; + artifact.*.error_section = 3; + artifact.*.error_index = index; + artifact.*.navigation_error = built.error; + artifact.*.display_error = built.display_error; + artifact.*.hit_error = built.hit_error; + artifact.*.semantics_error = built.semantics_error; + semantics_tree_free(&scratch_semantics); + hit_tree_free(&scratch_hits); + display_list_free(&scratch_display); + return 0; + } + let checkpoint: ComponentStateGalleryCheckpoint = + component_state_gallery_checkpoint(artifact); + let clear_focus: NodeKey = semantic_none_key(); + if (index == 2 && node_key_equal(artifact.*.runtime_state.focused, + placement.id) == 0) { clear_focus = placement.id; } + let merged: ComponentStateGalleryMergeResult = + component_state_gallery_merge_scratch(artifact, + &scratch_display, &scratch_hits, &scratch_semantics, + 1, artifact.*.id, clear_focus, + component_state_gallery_state_value(index), 1, 1); + semantics_tree_free(&scratch_semantics); + hit_tree_free(&scratch_hits); + display_list_free(&scratch_display); + if (merged.error != ComponentStateGalleryMergeError.none) { + component_state_gallery_record_merge_error(artifact, merged, 3, index); + return 0; + } + let navigation: NavigationItemArtifact = built.artifact; + navigation.display_op_start = merged.display_start; + navigation.display_op_end = merged.display_end; + navigation.hit_node_index = merged.hit_start; + navigation.semantics_node_index = merged.semantics_start; + if (component_state_gallery_emit_text(artifact, face, placement.id, + placement.id, navigation.label_content_bounds, "Project", + navigation.label_type_token, navigation.label_color_token, + 0, (navigation.effectively_enabled == 0) as i32, + 3, index) == 0) { + component_state_gallery_rollback(artifact, checkpoint); + return 0; + } + if (index == 2) { + artifact.*.focus_snapshot_count = + artifact.*.focus_snapshot_count + 1; + } + push[NavigationItemArtifact](&artifact.*.navigation_items, navigation); + return 1; +} + +fn component_state_gallery_segment_options(root: NodeKey) + ArrayList[SegmentedControlOption] { + let options: ArrayList[SegmentedControlOption] = + make[SegmentedControlOption](component_state_gallery_segment_count()); + let index: i32 = 0; + while (index < component_state_gallery_segment_count()) { + push[SegmentedControlOption](&options, SegmentedControlOption{ + .id = component_state_gallery_segment_id(root, index), + .label = component_state_gallery_segment_label(index), + .description = component_state_gallery_segment_description(index), + }); + index = index + 1; + } + return options; +} + +fn component_state_gallery_segment_model() SegmentedControlModel { + let model: SegmentedControlModel = segmented_control_model( + component_state_gallery_segment_count(), 0); + let disabled: SegmentedControlTransition = segmented_control_reduce(model, + segmented_control_event(SegmentedControlEventKind.disable_target, 2)); + return disabled.model; +} + +fn component_state_gallery_segment_index(root: NodeKey, + target: NodeKey) i32 { + let index: i32 = 0; + while (index < component_state_gallery_segment_count()) { + if (node_key_equal(target, + component_state_gallery_segment_id(root, index)) != 0) { + return index; + } + index = index + 1; + } + return segmented_control_no_index(); +} + +fn component_state_gallery_target_enabled(root: NodeKey, + target: NodeKey) i32 { + let index: i32 = 0; + while (index < component_state_gallery_surface_state_count()) { + if (node_key_equal(target, + component_state_gallery_surface_id(root, index)) != 0) { + return (index != 5 && index != 6) as i32; + } + index = index + 1; + } + index = 0; + while (index < component_state_gallery_button_state_count()) { + if (node_key_equal(target, + component_state_gallery_button_id(root, index)) != 0) { + return (index != 5 && index != 6) as i32; + } + index = index + 1; + } + index = 0; + while (index < component_state_gallery_navigation_state_count()) { + if (node_key_equal(target, + component_state_gallery_navigation_id(root, index)) != 0) { + return (index != 5) as i32; + } + index = index + 1; + } + let segment: i32 = component_state_gallery_segment_index(root, target); + return (segment >= 0 && segment != 2) as i32; +} + +fn component_state_gallery_runtime_state_default() + ComponentStateGalleryRuntimeState { + return ComponentStateGalleryRuntimeState{ + .hovered = semantic_none_key(), + .pressed = semantic_none_key(), + .focused = semantic_none_key(), + .focus_visible = 0, + .segmented = component_state_gallery_segment_model(), + .last_activated = semantic_none_key(), + .activation_count = 0, + .revision = 0, + }; +} + +fn component_state_gallery_frozen_runtime_state(root: NodeKey) + ComponentStateGalleryRuntimeState { + let state: ComponentStateGalleryRuntimeState = + component_state_gallery_runtime_state_default(); + let focused: SegmentedControlTransition = segmented_control_reduce( + state.segmented, + segmented_control_event(SegmentedControlEventKind.focus_target, 1)); + state.segmented = focused.model; + state.focused = component_state_gallery_segment_id(root, 1); + state.focus_visible = 1; + return state; +} + +fn component_state_gallery_segmented_model_equal( + left: SegmentedControlModel, right: SegmentedControlModel) i32 { + return (left.segment_count == right.segment_count && + left.selected_index == right.selected_index && + left.roving_index == right.roving_index && + left.has_focus == right.has_focus && + left.focus_visible == right.focus_visible && + left.hovered_index == right.hovered_index && + left.pressed_index == right.pressed_index && + left.enabled_mask == right.enabled_mask) as i32; +} + +fn component_state_gallery_runtime_payload_equal( + left: ComponentStateGalleryRuntimeState, + right: ComponentStateGalleryRuntimeState) i32 { + return (node_key_equal(left.hovered, right.hovered) != 0 && + node_key_equal(left.pressed, right.pressed) != 0 && + node_key_equal(left.focused, right.focused) != 0 && + left.focus_visible == right.focus_visible && + component_state_gallery_segmented_model_equal( + left.segmented, right.segmented) != 0 && + node_key_equal(left.last_activated, right.last_activated) != 0 && + left.activation_count == right.activation_count) as i32; +} + +fn component_state_gallery_runtime_state_hash( + state: ComponentStateGalleryRuntimeState) i64 { + let hash: i64 = 17; + hash = (hash * 1099511628211) ^ state.hovered.value; + hash = (hash * 1099511628211) ^ state.hovered.generation; + hash = (hash * 1099511628211) ^ state.pressed.value; + hash = (hash * 1099511628211) ^ state.pressed.generation; + hash = (hash * 1099511628211) ^ state.focused.value; + hash = (hash * 1099511628211) ^ state.focused.generation; + hash = (hash * 1099511628211) ^ state.focus_visible as i64; + hash = (hash * 1099511628211) ^ state.segmented.segment_count as i64; + hash = (hash * 1099511628211) ^ state.segmented.selected_index as i64; + hash = (hash * 1099511628211) ^ state.segmented.roving_index as i64; + hash = (hash * 1099511628211) ^ state.segmented.has_focus as i64; + hash = (hash * 1099511628211) ^ state.segmented.focus_visible as i64; + hash = (hash * 1099511628211) ^ state.segmented.hovered_index as i64; + hash = (hash * 1099511628211) ^ state.segmented.pressed_index as i64; + hash = (hash * 1099511628211) ^ state.segmented.enabled_mask; + hash = (hash * 1099511628211) ^ state.last_activated.value; + hash = (hash * 1099511628211) ^ state.last_activated.generation; + hash = (hash * 1099511628211) ^ state.activation_count; + hash = (hash * 1099511628211) ^ state.revision; + if (hash == 0) { return 1; } + return hash; +} + +fn component_state_gallery_runtime_state_valid(root: NodeKey, + state: ComponentStateGalleryRuntimeState) i32 { + if (canvas_key_valid(root) == 0 || + root.value > component_state_gallery_max_root_id() || + (state.focus_visible != 0 && state.focus_visible != 1) || + state.activation_count < 0 || state.revision < 0 || + state.revision < state.activation_count || + segmented_control_model_valid(state.segmented) == 0 || + state.segmented.segment_count != + component_state_gallery_segment_count() || + segmented_control_index_enabled(state.segmented, 0) == 0 || + segmented_control_index_enabled(state.segmented, 1) == 0 || + segmented_control_index_enabled(state.segmented, 2) != 0 || + segmented_control_index_enabled(state.segmented, 3) == 0) { + return 0; + } + let none: NodeKey = semantic_none_key(); + if ((node_key_equal(state.hovered, none) == 0 && + component_state_gallery_target_enabled(root, + state.hovered) == 0) || + (node_key_equal(state.pressed, none) == 0 && + component_state_gallery_target_enabled(root, + state.pressed) == 0) || + (node_key_equal(state.focused, none) == 0 && + component_state_gallery_target_enabled(root, + state.focused) == 0) || + (node_key_equal(state.last_activated, none) == 0 && + component_state_gallery_target_enabled(root, + state.last_activated) == 0)) { return 0; } + if ((node_key_equal(state.focused, none) != 0 && + state.focus_visible != 0) || + (state.activation_count == 0 && + node_key_equal(state.last_activated, none) == 0) || + (state.activation_count > 0 && + node_key_equal(state.last_activated, none) != 0)) { return 0; } + + let hovered_segment: i32 = component_state_gallery_segment_index( + root, state.hovered); + let pressed_segment: i32 = component_state_gallery_segment_index( + root, state.pressed); + let focused_segment: i32 = component_state_gallery_segment_index( + root, state.focused); + if ((hovered_segment >= 0 && + state.segmented.hovered_index != hovered_segment) || + (hovered_segment < 0 && + state.segmented.hovered_index != segmented_control_no_index()) || + (pressed_segment >= 0 && + state.segmented.pressed_index != pressed_segment) || + (pressed_segment < 0 && + state.segmented.pressed_index != segmented_control_no_index())) { + return 0; + } + if (focused_segment >= 0) { + if (state.segmented.has_focus == 0 || + state.segmented.roving_index != focused_segment || + state.segmented.focus_visible != state.focus_visible) { return 0; } + } else if (state.segmented.has_focus != 0 || + state.segmented.focus_visible != 0) { return 0; } + return 1; +} + +fn component_state_gallery_event(kind: ComponentStateGalleryEventKind, + target: NodeKey) ComponentStateGalleryEvent { + return ComponentStateGalleryEvent{ + .kind = kind, + .target = target, + .segmented_kind = SegmentedControlEventKind.pointer_cancel, + .segmented_target_index = segmented_control_no_index(), + }; +} + +fn component_state_gallery_segmented_event(kind: SegmentedControlEventKind, + target_index: i32) ComponentStateGalleryEvent { + return ComponentStateGalleryEvent{ + .kind = ComponentStateGalleryEventKind.segmented, + .target = semantic_none_key(), + .segmented_kind = kind, + .segmented_target_index = target_index, + }; +} + +fn component_state_gallery_transition( + current: ComponentStateGalleryRuntimeState) + ComponentStateGalleryTransition { + return ComponentStateGalleryTransition{ + .state = current, + .state_changed = 0, + .focus_changed = 0, + .activation_changed = 0, + .selection_changed = 0, + .rejected = 0, + }; +} + +fn component_state_gallery_clear_segment_pointer( + state: *ComponentStateGalleryRuntimeState) void { + state.*.segmented.hovered_index = segmented_control_no_index(); + state.*.segmented.pressed_index = segmented_control_no_index(); +} + +fn component_state_gallery_clear_segment_focus( + state: *ComponentStateGalleryRuntimeState) void { + state.*.segmented.has_focus = 0; + state.*.segmented.focus_visible = 0; + state.*.segmented.pressed_index = segmented_control_no_index(); +} + +fn component_state_gallery_record_activation( + transition: *ComponentStateGalleryTransition, + target: NodeKey) void { + transition.*.state.last_activated = target; + transition.*.state.activation_count = + transition.*.state.activation_count + 1; + transition.*.activation_changed = 1; +} + +fn component_state_gallery_apply_segmented_transition( + transition: *ComponentStateGalleryTransition, + root: NodeKey, segmented: SegmentedControlTransition) void { + if (segmented.rejected != 0) { + transition.*.rejected = 1; + return; + } + let old_selected: i32 = transition.*.state.segmented.selected_index; + let old_focused: NodeKey = transition.*.state.focused; + transition.*.state.segmented = segmented.model; + let old_hover_segment: i32 = component_state_gallery_segment_index( + root, transition.*.state.hovered); + if (segmented.model.hovered_index >= 0) { + transition.*.state.hovered = component_state_gallery_segment_id( + root, segmented.model.hovered_index); + } else if (old_hover_segment >= 0) { + transition.*.state.hovered = semantic_none_key(); + } + let old_pressed_segment: i32 = component_state_gallery_segment_index( + root, transition.*.state.pressed); + if (segmented.model.pressed_index >= 0) { + transition.*.state.pressed = component_state_gallery_segment_id( + root, segmented.model.pressed_index); + } else if (old_pressed_segment >= 0) { + transition.*.state.pressed = semantic_none_key(); + } + if (segmented.model.has_focus != 0) { + transition.*.state.focused = component_state_gallery_segment_id( + root, segmented.model.roving_index); + transition.*.state.focus_visible = segmented.model.focus_visible; + } else if (component_state_gallery_segment_index(root, + old_focused) >= 0) { + transition.*.state.focused = semantic_none_key(); + transition.*.state.focus_visible = 0; + } + if (old_selected != segmented.model.selected_index) { + transition.*.selection_changed = 1; + } + if (segmented.activated != 0) { + component_state_gallery_record_activation(transition, + component_state_gallery_segment_id(root, + segmented.model.selected_index)); + } +} + +fn component_state_gallery_reduce( + current: ComponentStateGalleryRuntimeState, + root: NodeKey, event: ComponentStateGalleryEvent) + ComponentStateGalleryTransition { + let result: ComponentStateGalleryTransition = + component_state_gallery_transition(current); + if (component_state_gallery_runtime_state_valid(root, current) == 0) { + result.rejected = 1; + return result; + } + if (event.kind == ComponentStateGalleryEventKind.segmented) { + if (event.segmented_kind == SegmentedControlEventKind.disable_target || + event.segmented_kind == SegmentedControlEventKind.enable_target) { + result.rejected = 1; + return result; + } + let segmented: SegmentedControlTransition = segmented_control_reduce( + current.segmented, segmented_control_event(event.segmented_kind, + event.segmented_target_index)); + component_state_gallery_apply_segmented_transition(&result, + root, segmented); + } else if (event.kind == ComponentStateGalleryEventKind.pointer_cancel) { + result.state.pressed = semantic_none_key(); + let segmented: SegmentedControlTransition = segmented_control_reduce( + current.segmented, segmented_control_event( + SegmentedControlEventKind.pointer_cancel, + segmented_control_no_index())); + component_state_gallery_apply_segmented_transition(&result, + root, segmented); + } else { + if (component_state_gallery_target_enabled(root, event.target) == 0) { + result.rejected = 1; + return result; + } + let segment: i32 = component_state_gallery_segment_index( + root, event.target); + switch (event.kind) { + .pointer_enter => { + if (segment >= 0) { + let segmented: SegmentedControlTransition = + segmented_control_reduce(current.segmented, + segmented_control_event( + SegmentedControlEventKind.pointer_enter, + segment)); + component_state_gallery_apply_segmented_transition( + &result, root, segmented); + } else { + result.state.hovered = event.target; + result.state.segmented.hovered_index = + segmented_control_no_index(); + } + } + .pointer_leave => { + if (segment >= 0) { + let segmented: SegmentedControlTransition = + segmented_control_reduce(current.segmented, + segmented_control_event( + SegmentedControlEventKind.pointer_leave, + segment)); + component_state_gallery_apply_segmented_transition( + &result, root, segmented); + } else { + if (node_key_equal(result.state.hovered, + event.target) != 0) { + result.state.hovered = semantic_none_key(); + } + if (node_key_equal(result.state.pressed, + event.target) != 0) { + result.state.pressed = semantic_none_key(); + } + } + } + .pointer_down => { + if (segment >= 0) { + let segmented: SegmentedControlTransition = + segmented_control_reduce(current.segmented, + segmented_control_event( + SegmentedControlEventKind.pointer_down, + segment)); + component_state_gallery_apply_segmented_transition( + &result, root, segmented); + } else { + result.state.hovered = event.target; + result.state.pressed = event.target; + result.state.focused = event.target; + result.state.focus_visible = 0; + component_state_gallery_clear_segment_pointer( + &result.state); + component_state_gallery_clear_segment_focus( + &result.state); + } + } + .pointer_up_inside => { + if (segment >= 0) { + let segmented: SegmentedControlTransition = + segmented_control_reduce(current.segmented, + segmented_control_event( + SegmentedControlEventKind.pointer_up_inside, + segment)); + component_state_gallery_apply_segmented_transition( + &result, root, segmented); + } else if (node_key_equal(current.pressed, + event.target) != 0) { + result.state.pressed = semantic_none_key(); + component_state_gallery_record_activation(&result, + event.target); + } else { + result.rejected = 1; + } + } + .focus_gained_pointer => { + result.state.focused = event.target; + result.state.focus_visible = 0; + if (segment >= 0) { + result.state.segmented.roving_index = segment; + result.state.segmented.has_focus = 1; + result.state.segmented.focus_visible = 0; + } else { + if (component_state_gallery_segment_index(root, + result.state.pressed) >= 0) { + result.state.pressed = semantic_none_key(); + } + component_state_gallery_clear_segment_focus( + &result.state); + } + } + .focus_gained_keyboard => { + result.state.focused = event.target; + result.state.focus_visible = 1; + if (segment >= 0) { + let segmented: SegmentedControlTransition = + segmented_control_reduce(current.segmented, + segmented_control_event( + SegmentedControlEventKind.focus_target, + segment)); + component_state_gallery_apply_segmented_transition( + &result, root, segmented); + } else { + if (component_state_gallery_segment_index(root, + result.state.pressed) >= 0) { + result.state.pressed = semantic_none_key(); + } + component_state_gallery_clear_segment_focus( + &result.state); + } + } + .focus_lost => { + if (node_key_equal(current.focused, event.target) != 0) { + result.state.focused = semantic_none_key(); + result.state.focus_visible = 0; + if (node_key_equal(result.state.pressed, + event.target) != 0) { + result.state.pressed = semantic_none_key(); + } + if (segment >= 0) { + component_state_gallery_clear_segment_focus( + &result.state); + } + } + } + .activate_target => { + if (segment >= 0) { + let segmented: SegmentedControlTransition = + segmented_control_reduce(current.segmented, + segmented_control_event( + SegmentedControlEventKind.select_target, + segment)); + component_state_gallery_apply_segmented_transition( + &result, root, segmented); + } else { + component_state_gallery_record_activation(&result, + event.target); + } + } + .pointer_cancel => { } + .segmented => { } + } + } + if (result.rejected != 0) { + result.state = current; + result.state_changed = 0; + result.focus_changed = 0; + result.activation_changed = 0; + result.selection_changed = 0; + return result; + } + result.focus_changed = (node_key_equal(current.focused, + result.state.focused) == 0 || + current.focus_visible != result.state.focus_visible) as i32; + if (component_state_gallery_runtime_payload_equal(current, + result.state) == 0) { + result.state.revision = current.revision + 1; + result.state_changed = 1; + } + if (component_state_gallery_runtime_state_valid(root, + result.state) == 0) { + result.state = current; + result.state_changed = 0; + result.focus_changed = 0; + result.activation_changed = 0; + result.selection_changed = 0; + result.rejected = 1; + } + return result; +} + +fn component_state_gallery_emit_segmented( + artifact: *ComponentStateGalleryArtifact, + face: *OpenTypeFace) i32 { + let options: ArrayList[SegmentedControlOption] = + component_state_gallery_segment_options(artifact.*.id); + let checkpoint: ComponentStateGalleryCheckpoint = + component_state_gallery_checkpoint(artifact); + let model: SegmentedControlModel = artifact.*.runtime_state.segmented; + let spec: SegmentedControlSpec = segmented_control_spec( + component_state_gallery_segmented_id(artifact.*.id), + semantic_root_key(), artifact.*.layout.segmented_bounds, + "Component state behavior", model); + spec.description = + "Selected disabled resting and runtime focus-visible segments in one real control"; + spec.focus_order_base = 400; + spec.z_order = 400; + spec.density = artifact.*.density; + let scratch_display: DisplayList = display_list_make(); + let scratch_hits: HitTree = hit_tree_make(); + let scratch_semantics: SemanticsTree = semantics_tree_make(); + let built: SegmentedControlBuildResult = segmented_control_emit(spec, + &options, &scratch_display, &scratch_hits, &scratch_semantics); + if (built.error != SegmentedControlBuildError.none) { + artifact.*.error = ComponentStateGalleryError.segmented_error; + artifact.*.error_section = 4; + artifact.*.segmented_error = built.error; + artifact.*.display_error = built.display_error; + artifact.*.hit_error = built.hit_error; + artifact.*.semantics_error = built.semantics_error; + semantics_tree_free(&scratch_semantics); + hit_tree_free(&scratch_hits); + display_list_free(&scratch_display); + free[SegmentedControlOption](&options); + return 0; + } + let merged: ComponentStateGalleryMergeResult = + component_state_gallery_merge_scratch(artifact, + &scratch_display, &scratch_hits, &scratch_semantics, + 1, artifact.*.id, semantic_none_key(), "", + component_state_gallery_segment_count() + 1, + component_state_gallery_segment_count() + 1); + semantics_tree_free(&scratch_semantics); + hit_tree_free(&scratch_hits); + display_list_free(&scratch_display); + if (merged.error != ComponentStateGalleryMergeError.none) { + component_state_gallery_record_merge_error(artifact, merged, 4, 0); + free[SegmentedControlOption](&options); + return 0; + } + artifact.*.segmented = built.artifact; + artifact.*.segmented.display_op_start = merged.display_start; + artifact.*.segmented.display_op_end = merged.display_end; + artifact.*.segmented.group_hit_node_index = merged.hit_start; + artifact.*.segmented.group_semantics_node_index = merged.semantics_start; + let added_resource_ids: ArrayList[i64] = make[i64]( + component_state_gallery_segment_count()); + let index: i32 = 0; + while (index < component_state_gallery_segment_count()) { + let style: SegmentedControlResolvedStyle = segmented_control_style( + spec, index); + if (component_state_gallery_emit_text(artifact, face, + options.data[index].id, options.data[index].id, + segmented_control_content_bounds(spec, index), + options.data[index].label, artifact.*.segmented.label_type_token, + style.label_color_token, 0, style.disabled, 4, index) == 0) { + if (component_state_gallery_rollback_text_resources(artifact, + checkpoint, &added_resource_ids) == 0) { + artifact.*.error = ComponentStateGalleryError.display_error; + artifact.*.display_error = DisplayError.content_mutated; + } + free[i64](&added_resource_ids); + free[SegmentedControlOption](&options); + return 0; + } + push[i64](&added_resource_ids, canvas_resource_id( + options.data[index].id, text_local_path_resource_id())); + index = index + 1; + } + artifact.*.live_focus_id = artifact.*.runtime_state.focused; + free[i64](&added_resource_ids); + free[SegmentedControlOption](&options); + return 1; +} + +fn component_state_gallery_mix(hash: i64, value: i64) i64 { + return (hash * 1099511628211) ^ value; +} + +fn component_state_gallery_rect_hash(hash: i64, value: Rect) i64 { + let result: i64 = component_state_gallery_mix(hash, value.x); + result = component_state_gallery_mix(result, value.y); + result = component_state_gallery_mix(result, value.width); + return component_state_gallery_mix(result, value.height); +} + +fn component_state_gallery_placement_hash(hash: i64, + placement: ComponentStateGalleryPlacement) i64 { + let result: i64 = component_state_gallery_mix(hash, + placement.id.value); + result = component_state_gallery_mix(result, placement.id.generation); + result = component_state_gallery_mix(result, + placement.caption_id.value); + result = component_state_gallery_rect_hash(result, + placement.cell_bounds); + result = component_state_gallery_rect_hash(result, + placement.caption_bounds); + return component_state_gallery_rect_hash(result, + placement.control_bounds); +} + +fn component_state_gallery_hit_hash(tree: *HitTree) i64 { + let hash: i64 = component_state_gallery_mix(17, + tree.*.nodes.len as i64); + hash = component_state_gallery_mix(hash, tree.*.revision); + let index: i32 = 0; + while (index < tree.*.nodes.len) { + let node: HitNode = tree.*.nodes.data[index]; + hash = component_state_gallery_mix(hash, node.id.value); + hash = component_state_gallery_mix(hash, node.id.generation); + hash = component_state_gallery_mix(hash, node.parent.value); + hash = component_state_gallery_rect_hash(hash, node.bounds); + hash = component_state_gallery_mix(hash, node.z_order); + hash = component_state_gallery_mix(hash, node.enabled as i64); + hash = component_state_gallery_mix(hash, node.focusable as i64); + index = index + 1; + } + if (hash == 0) { return 1; } + return hash; +} + +fn component_state_gallery_calculate_hash( + artifact: *ComponentStateGalleryArtifact) i64 { + let hash: i64 = component_state_gallery_mix(17, artifact.*.id.value); + hash = component_state_gallery_mix(hash, artifact.*.id.generation); + hash = component_state_gallery_rect_hash(hash, artifact.*.bounds); + hash = component_state_gallery_mix(hash, artifact.*.density as i64); + hash = component_state_gallery_mix(hash, artifact.*.direction as i64); + hash = component_state_gallery_mix(hash, + artifact.*.layout.required_height); + hash = component_state_gallery_mix(hash, + artifact.*.layout.minimum_width); + hash = component_state_gallery_rect_hash(hash, + artifact.*.layout.title_bounds); + let index: i32 = 0; + while (index < artifact.*.layout.section_heading_bounds.len) { + hash = component_state_gallery_rect_hash(hash, + artifact.*.layout.section_heading_bounds.data[index]); + index = index + 1; + } + index = 0; + while (index < artifact.*.layout.tier_placements.len) { + hash = component_state_gallery_placement_hash(hash, + artifact.*.layout.tier_placements.data[index]); + index = index + 1; + } + index = 0; + while (index < artifact.*.layout.surface_placements.len) { + hash = component_state_gallery_placement_hash(hash, + artifact.*.layout.surface_placements.data[index]); + index = index + 1; + } + index = 0; + while (index < artifact.*.layout.button_placements.len) { + hash = component_state_gallery_placement_hash(hash, + artifact.*.layout.button_placements.data[index]); + index = index + 1; + } + index = 0; + while (index < artifact.*.layout.navigation_placements.len) { + hash = component_state_gallery_placement_hash(hash, + artifact.*.layout.navigation_placements.data[index]); + index = index + 1; + } + hash = component_state_gallery_rect_hash(hash, + artifact.*.layout.segmented_bounds); + index = 0; + while (index < artifact.*.tier_surfaces.len) { + hash = component_state_gallery_mix(hash, + artifact.*.tier_surfaces.data[index].evidence_hash); + index = index + 1; + } + index = 0; + while (index < artifact.*.state_surfaces.len) { + hash = component_state_gallery_mix(hash, + artifact.*.state_surfaces.data[index].evidence_hash); + index = index + 1; + } + index = 0; + while (index < artifact.*.buttons.len) { + hash = component_state_gallery_mix(hash, + artifact.*.buttons.data[index].evidence_hash); + index = index + 1; + } + index = 0; + while (index < artifact.*.navigation_items.len) { + hash = component_state_gallery_mix(hash, + artifact.*.navigation_items.data[index].evidence_hash); + index = index + 1; + } + hash = component_state_gallery_mix(hash, + artifact.*.segmented.evidence_hash); + hash = component_state_gallery_mix(hash, + artifact.*.text_artifact_count as i64); + hash = component_state_gallery_mix(hash, + artifact.*.text_semantic_count as i64); + hash = component_state_gallery_mix(hash, + artifact.*.focus_snapshot_count as i64); + hash = component_state_gallery_mix(hash, + artifact.*.live_focus_id.value); + hash = component_state_gallery_mix(hash, artifact.*.runtime_hash); + hash = component_state_gallery_mix(hash, + artifact.*.frozen_conformance as i64); + hash = component_state_gallery_mix(hash, artifact.*.display_hash); + hash = component_state_gallery_mix(hash, artifact.*.hit_hash); + hash = component_state_gallery_mix(hash, artifact.*.talkback_hash); + if (hash == 0) { return 1; } + return hash; +} + +fn component_state_gallery_focused_semantics_count( + semantics: *SemanticsTree) i32 { + let count: i32 = 0; + let index: i32 = 0; + while (index < semantics.*.nodes.len) { + if (semantics.*.nodes.data[index].focused != 0) { + count = count + 1; + } + index = index + 1; + } + return count; +} + +fn component_state_gallery_valid( + artifact: *ComponentStateGalleryArtifact) i32 { + let expected_focus_count: i32 = 1; + if (node_key_equal(artifact.*.runtime_state.focused, + semantic_none_key()) != 0) { expected_focus_count = 0; } + if (artifact.*.error != ComponentStateGalleryError.none || + artifact.*.layout.flex_error != FlexError.none || + artifact.*.layout.overflow != 0 || + artifact.*.layout.section_heading_bounds.len != + component_state_gallery_section_count() || + artifact.*.layout.tier_placements.len != + component_state_gallery_tier_count() || + artifact.*.layout.surface_placements.len != + component_state_gallery_surface_state_count() || + artifact.*.layout.button_placements.len != + component_state_gallery_button_state_count() || + artifact.*.layout.navigation_placements.len != + component_state_gallery_navigation_state_count() || + artifact.*.tier_surfaces.len != + component_state_gallery_tier_count() || + artifact.*.state_surfaces.len != + component_state_gallery_surface_state_count() || + artifact.*.buttons.len != + component_state_gallery_button_state_count() || + artifact.*.navigation_items.len != + component_state_gallery_navigation_state_count() || + artifact.*.segmented.segment_count != + component_state_gallery_segment_count() || + artifact.*.text_artifact_count != + component_state_gallery_expected_text_count() || + artifact.*.text_semantic_count != + component_state_gallery_expected_text_semantics() || + artifact.*.display.ops.len <= 0 || + artifact.*.display.resources.resources.len != + component_state_gallery_expected_display_resources() || + artifact.*.hits.nodes.len != + component_state_gallery_expected_hit_nodes() || + artifact.*.semantics.nodes.len != + component_state_gallery_expected_semantic_nodes() || + artifact.*.read_only_surface_hit_count != 0 || + artifact.*.focus_snapshot_count != 3 || + node_key_equal(artifact.*.live_focus_id, + artifact.*.runtime_state.focused) == 0 || + component_state_gallery_focused_semantics_count( + &artifact.*.semantics) != expected_focus_count || + component_state_gallery_runtime_state_valid(artifact.*.id, + artifact.*.runtime_state) == 0 || + artifact.*.runtime_hash == 0 || + artifact.*.runtime_hash != component_state_gallery_runtime_state_hash( + artifact.*.runtime_state) || + (artifact.*.frozen_conformance != 0 && + artifact.*.frozen_conformance != 1) || + artifact.*.display_hash == 0 || artifact.*.hit_hash == 0 || + artifact.*.talkback_hash == 0 || artifact.*.evidence_hash == 0 || + display_list_verify(&artifact.*.display) != DisplayError.none || + artifact.*.display_hash != artifact.*.display.content_hash || + artifact.*.hit_hash != component_state_gallery_hit_hash( + &artifact.*.hits) || + artifact.*.talkback_hash != talkback_tree_hash( + artifact.*.semantics) || + artifact.*.evidence_hash != + component_state_gallery_calculate_hash(artifact)) { return 0; } + if (expected_focus_count != 0) { + let live_index: i32 = semantics_find_index_borrowed( + &artifact.*.semantics, artifact.*.live_focus_id); + if (live_index < 0 || + artifact.*.semantics.nodes.data[live_index].focused == 0) { + return 0; + } + } + let index: i32 = 0; + while (index < component_state_gallery_tier_count()) { + let id: NodeKey = component_state_gallery_tier_id( + artifact.*.id, index); + let semantic_index: i32 = semantics_find_index_borrowed( + &artifact.*.semantics, id); + if (hit_tree_find_index_borrowed(&artifact.*.hits, id) >= 0 || + semantic_index < 0 || + artifact.*.semantics.nodes.data[semantic_index].role != + SemanticRole.group || + artifact.*.semantics.nodes.data[semantic_index].action_mask != 0) { + return 0; + } + index = index + 1; + } + return 1; +} + +fn component_state_gallery_build_with_state( + spec: ComponentStateGallerySpec, + face: *OpenTypeFace, + runtime: ComponentStateGalleryRuntimeState) + ComponentStateGalleryArtifact { + let artifact: ComponentStateGalleryArtifact = + component_state_gallery_empty(spec); + if (component_state_gallery_spec_valid(spec) == 0) { + artifact.error = ComponentStateGalleryError.invalid_spec; + return artifact; + } + artifact.runtime_state = runtime; + artifact.runtime_hash = component_state_gallery_runtime_state_hash( + runtime); + if (component_state_gallery_runtime_state_valid(spec.id, runtime) == 0) { + artifact.error = ComponentStateGalleryError.invalid_runtime_state; + return artifact; + } + if (text_font_valid(face.*) == 0) { + artifact.error = ComponentStateGalleryError.missing_font; + return artifact; + } + component_state_gallery_layout_free(&artifact.layout); + artifact.layout = component_state_gallery_layout(spec); + if (artifact.layout.flex_error != FlexError.none) { + artifact.error = ComponentStateGalleryError.layout_error; + artifact.flex_error = artifact.layout.flex_error; + return artifact; + } + if (artifact.layout.overflow != 0) { + artifact.error = ComponentStateGalleryError.layout_overflow; + return artifact; + } + if (component_state_gallery_root_semantics(&artifact, spec) == 0) { + return artifact; + } + if (component_state_gallery_emit_text(&artifact, face, + component_state_gallery_title_id(spec.id), spec.id, + artifact.layout.title_bounds, spec.name, SemanticTypeToken.title, + SemanticColorToken.text_primary, 1, 0, 0 - 1, 0) == 0) { + return artifact; + } + let index: i32 = 0; + if (component_state_gallery_emit_text(&artifact, face, + component_state_gallery_heading_id(spec.id, 0), spec.id, + artifact.layout.section_heading_bounds.data[0], + component_state_gallery_section_name(0), + SemanticTypeToken.heading, SemanticColorToken.text_primary, + 1, 0, 0, 0 - 1) == 0) { return artifact; } + while (index < component_state_gallery_tier_count()) { + let placement: ComponentStateGalleryPlacement = + artifact.layout.tier_placements.data[index]; + if (component_state_gallery_emit_text(&artifact, face, + placement.caption_id, spec.id, placement.caption_bounds, + component_state_gallery_tier_name(index), + SemanticTypeToken.caption, SemanticColorToken.text_secondary, + 1, 0, 0, index) == 0 || + component_state_gallery_emit_tier_surface(&artifact, face, + placement, index) == 0) { return artifact; } + index = index + 1; + } + index = 0; + if (component_state_gallery_emit_text(&artifact, face, + component_state_gallery_heading_id(spec.id, 1), spec.id, + artifact.layout.section_heading_bounds.data[1], + component_state_gallery_section_name(1), + SemanticTypeToken.heading, SemanticColorToken.text_primary, + 1, 0, 1, 0 - 1) == 0) { return artifact; } + while (index < component_state_gallery_surface_state_count()) { + let placement: ComponentStateGalleryPlacement = + artifact.layout.surface_placements.data[index]; + if (component_state_gallery_emit_text(&artifact, face, + placement.caption_id, spec.id, placement.caption_bounds, + component_state_gallery_state_name(index), + SemanticTypeToken.caption, SemanticColorToken.text_secondary, + 1, 0, 1, index) == 0 || + component_state_gallery_emit_state_surface(&artifact, face, + placement, index) == 0) { return artifact; } + index = index + 1; + } + index = 0; + if (component_state_gallery_emit_text(&artifact, face, + component_state_gallery_heading_id(spec.id, 2), spec.id, + artifact.layout.section_heading_bounds.data[2], + component_state_gallery_section_name(2), + SemanticTypeToken.heading, SemanticColorToken.text_primary, + 1, 0, 2, 0 - 1) == 0) { return artifact; } + while (index < component_state_gallery_button_state_count()) { + let placement: ComponentStateGalleryPlacement = + artifact.layout.button_placements.data[index]; + if (component_state_gallery_emit_text(&artifact, face, + placement.caption_id, spec.id, placement.caption_bounds, + component_state_gallery_state_name(index), + SemanticTypeToken.caption, SemanticColorToken.text_secondary, + 1, 0, 2, index) == 0 || + component_state_gallery_emit_button(&artifact, face, + placement, index) == 0) { return artifact; } + index = index + 1; + } + index = 0; + if (component_state_gallery_emit_text(&artifact, face, + component_state_gallery_heading_id(spec.id, 3), spec.id, + artifact.layout.section_heading_bounds.data[3], + component_state_gallery_section_name(3), + SemanticTypeToken.heading, SemanticColorToken.text_primary, + 1, 0, 3, 0 - 1) == 0) { return artifact; } + while (index < component_state_gallery_navigation_state_count()) { + let placement: ComponentStateGalleryPlacement = + artifact.layout.navigation_placements.data[index]; + if (component_state_gallery_emit_text(&artifact, face, + placement.caption_id, spec.id, placement.caption_bounds, + component_state_gallery_state_name(index), + SemanticTypeToken.caption, SemanticColorToken.text_secondary, + 1, 0, 3, index) == 0 || + component_state_gallery_emit_navigation(&artifact, face, + placement, index) == 0) { return artifact; } + index = index + 1; + } + if (component_state_gallery_emit_text(&artifact, face, + component_state_gallery_heading_id(spec.id, 4), spec.id, + artifact.layout.section_heading_bounds.data[4], + component_state_gallery_section_name(4), + SemanticTypeToken.heading, SemanticColorToken.text_primary, + 1, 0, 4, 0 - 1) == 0) { return artifact; } + if (component_state_gallery_emit_segmented(&artifact, face) == 0) { + return artifact; + } + let sealed: DisplayError = display_list_seal(&artifact.display); + if (sealed != DisplayError.none) { + artifact.error = ComponentStateGalleryError.display_error; + artifact.display_error = sealed; + return artifact; + } + artifact.display_hash = artifact.display.content_hash; + artifact.hit_hash = component_state_gallery_hit_hash(&artifact.hits); + artifact.talkback_hash = talkback_tree_hash(artifact.semantics); + artifact.evidence_hash = component_state_gallery_calculate_hash(&artifact); + return artifact; +} + +fn component_state_gallery_build( + spec: ComponentStateGallerySpec, + face: *OpenTypeFace) ComponentStateGalleryArtifact { + let runtime: ComponentStateGalleryRuntimeState = + component_state_gallery_runtime_state_default(); + return component_state_gallery_build_with_state(spec, face, runtime); +} + +fn component_state_gallery_build_frozen_conformance( + spec: ComponentStateGallerySpec, + face: *OpenTypeFace) ComponentStateGalleryArtifact { + let runtime: ComponentStateGalleryRuntimeState = + component_state_gallery_frozen_runtime_state(spec.id); + let artifact: ComponentStateGalleryArtifact = + component_state_gallery_build_with_state(spec, face, runtime); + if (artifact.error == ComponentStateGalleryError.none) { + artifact.frozen_conformance = 1; + artifact.evidence_hash = + component_state_gallery_calculate_hash(&artifact); + } + return artifact; +} + +fn component_state_gallery_free( + artifact: *ComponentStateGalleryArtifact) void { + free[NavigationItemArtifact](&artifact.*.navigation_items); + free[ButtonArtifact](&artifact.*.buttons); + free[SurfaceArtifact](&artifact.*.state_surfaces); + free[SurfaceArtifact](&artifact.*.tier_surfaces); + component_state_gallery_layout_free(&artifact.*.layout); + semantics_tree_free(&artifact.*.semantics); + hit_tree_free(&artifact.*.hits); + display_list_free(&artifact.*.display); + artifact.*.display_hash = 0; + artifact.*.hit_hash = 0; + artifact.*.talkback_hash = 0; + artifact.*.evidence_hash = 0; +} diff --git a/src/showcase/linux_preview_composition.zag b/src/showcase/linux_preview_composition.zag new file mode 100644 index 0000000..3366491 --- /dev/null +++ b/src/showcase/linux_preview_composition.zag @@ -0,0 +1,2447 @@ +@import("../automation/talkback.zag") +@import("../components/navigation_item.zag") +@import("../components/performance_chart.zag") +@import("../components/scroll_viewport.zag") +@import("../components/segmented_control.zag") +@import("../components/surface.zag") +@import("../components/text.zag") +@import("linux_preview_layout.zag") +@import("linux_preview_model.zag") +@import("component_state_gallery.zag") +@import("typography_specimen.zag") + +// First-party composition authority for the Linux showcase. One retained +// artifact owns the caller-visible display list, semantic tree, and hit tree. +// Every interactive component receives its exact bounds and stable identity +// from this file; there is no parallel pixel map. + +enum LinuxPreviewCompositionError { + none, + invalid_layout, + display_error, + semantics_error, + hit_error, + navigation_error, + button_error, + segmented_error, + surface_error, + chart_error, + chart_host_error, + typography_error, + typography_host_error, + gallery_error, + scroll_viewport_error, + text_error, + text_contribution_error, + seal_error, +} + +struct LinuxPreviewCompositionDiagnostics { + display_error: DisplayError, + semantics_error: SemanticsError, + hit_error: HitTreeError, + navigation_error: NavigationItemBuildError, + button_error: ButtonBuildError, + segmented_error: SegmentedControlBuildError, + surface_error: SurfaceBuildError, + surface_capacity_error: SurfaceCapacityError, + chart_error: PerformanceChartError, + chart_host_error: PerformanceChartHostError, + typography_error: TypographySpecimenError, + typography_host_error: TypographySpecimenHostError, + gallery_error: ComponentStateGalleryError, + scroll_viewport_error: ScrollViewportBuildError, + scroll_viewport_capacity_error: ScrollViewportCapacityError, + scroll_viewport_resource_error: RenderResourceError, + text_error: TextError, + text_contribution_error: TextContributionError, +} + +// Pointer, keyboard, and accessibility focus share one retained interaction +// truth. NodeKey identity is authoritative; components never infer state from +// colors or from a second pixel map. The segmented control retains its richer +// roving model separately because it owns four internal focus targets. +struct LinuxPreviewInteractionState { + hovered: NodeKey, + pressed: NodeKey, + focused: NodeKey, + focus_visible: i32, +} + +fn linux_preview_interaction_state() LinuxPreviewInteractionState { + return LinuxPreviewInteractionState{ + .hovered = hit_root_key(), + .pressed = hit_root_key(), + .focused = hit_root_key(), + .focus_visible = 0, + }; +} + +fn linux_preview_interaction_state_valid( + state: LinuxPreviewInteractionState) i32 { + if (state.focus_visible != 0 && state.focus_visible != 1) { return 0; } + if (state.focus_visible != 0 && + node_key_equal(state.focused, hit_root_key()) != 0) { return 0; } + return 1; +} + +fn linux_preview_interaction_for(state: LinuxPreviewInteractionState, + id: NodeKey, enabled: i32, selected: i32) ButtonInteraction { + let interaction: ButtonInteraction = button_interaction(); + interaction.enabled = (enabled != 0) as i32; + interaction.selected = (selected != 0) as i32; + if (interaction.enabled == 0) { return interaction; } + interaction.hovered = node_key_equal(state.hovered, id); + interaction.pressed = node_key_equal(state.pressed, id); + interaction.focused = node_key_equal(state.focused, id); + interaction.focus_visible = + (interaction.focused != 0 && state.focus_visible != 0) as i32; + return interaction; +} + +// Application state is the only mutable route authority. Composition consumes +// a value snapshot and publishes the normalized snapshot it actually rendered; +// callers retain the next snapshot across resize, input, and Talkback reveal. +struct LinuxPreviewAppState { + selected_navigation: i32, + overview_segmented: SegmentedControlModel, + token_inspector_open: i32, + interaction: LinuxPreviewInteractionState, + components_scroll: ScrollState, + gallery_runtime: ComponentStateGalleryRuntimeState, +} + +struct LinuxPreviewCompositionArtifact { + layout: LinuxPreviewLayout, + display: DisplayList, + semantics: SemanticsTree, + hits: HitTree, + error: LinuxPreviewCompositionError, + diagnostics: LinuxPreviewCompositionDiagnostics, + app_state: LinuxPreviewAppState, + interaction: LinuxPreviewInteractionState, + selected_navigation: i32, + components_available: i32, + typography_available: i32, + segmented_model: SegmentedControlModel, + token_inspector_open: i32, + navigation_items_composed: i32, + inspect_composed: i32, + token_inspector_composed: i32, + token_inspector_text_instances: i32, + segmented_control_composed: i32, + segmented_items_composed: i32, + surfaces_composed: i32, + chart_composed: i32, + chart_text_slots_composed: i32, + typography_composed: i32, + typography_rows_composed: i32, + typography_display_operations: i32, + typography_display_resources: i32, + typography_semantic_nodes: i32, + component_gallery_composed: i32, + component_gallery_display_operations: i32, + component_gallery_display_resources: i32, + component_gallery_hit_nodes: i32, + component_gallery_semantic_nodes: i32, + component_gallery_evidence_hash: i64, + component_viewport_composed: i32, + component_viewport_display_operations: i32, + component_viewport_display_resources: i32, + component_viewport_hit_nodes: i32, + component_viewport_semantic_nodes: i32, + component_viewport_state_hash: i64, + component_viewport_evidence_hash: i64, + component_viewport: ScrollViewportArtifact, + compact_summary_composed: i32, + unsupported_size_composed: i32, + status_items_composed: i32, + text_instances_composed: i32, + component_evidence_hash: i64, + evidence_hash: i64, +} + +fn linux_preview_composition_root_id() NodeKey { return node_key(20000); } +fn linux_preview_composition_header_id() NodeKey { return node_key(20050); } +fn linux_preview_composition_header_title_id() NodeKey { return node_key(20051); } +fn linux_preview_composition_header_status_id() NodeKey { return node_key(20052); } +fn linux_preview_composition_navigation_surface_id() NodeKey { + return node_key(20099); +} +fn linux_preview_composition_navigation_id() NodeKey { return node_key(20100); } +fn linux_preview_composition_nav_id(index: i32) NodeKey { + return node_key(20200 + index as i64); +} +fn linux_preview_composition_main_id() NodeKey { return node_key(20300); } +fn linux_preview_composition_heading_id() NodeKey { return node_key(20301); } +fn linux_preview_composition_heading_description_id() NodeKey { + return node_key(20304); +} +fn linux_preview_composition_inspect_id() NodeKey { return node_key(20302); } +fn linux_preview_composition_segmented_id() NodeKey { return node_key(20303); } +fn linux_preview_composition_segment_id(index: i32) NodeKey { + return node_key(20400 + index as i64); +} +fn linux_preview_composition_status_id() NodeKey { return node_key(20500); } +fn linux_preview_composition_status_surface_id() NodeKey { + return node_key(20499); +} +fn linux_preview_composition_status_heading_id() NodeKey { + return node_key(20497); +} +fn linux_preview_composition_status_caption_id() NodeKey { + return node_key(20498); +} +fn linux_preview_composition_status_item_id(index: i32) NodeKey { + return node_key(20600 + index as i64); +} +fn linux_preview_composition_summary_id() NodeKey { return node_key(20740); } +fn linux_preview_composition_summary_surface_id() NodeKey { + return node_key(20739); +} +fn linux_preview_composition_unsupported_id() NodeKey { return node_key(20750); } +fn linux_preview_composition_unsupported_surface_id() NodeKey { + return node_key(20749); +} +fn linux_preview_composition_unsupported_title_id() NodeKey { + return node_key(20751); +} +fn linux_preview_composition_unsupported_body_id() NodeKey { + return node_key(20752); +} +fn linux_preview_composition_chart_id() NodeKey { return node_key(27000); } +fn linux_preview_composition_chart_series_id(index: i32) NodeKey { + return node_key(27100 + index as i64); +} +fn linux_preview_composition_token_inspector_id() NodeKey { + return node_key(20800); +} +fn linux_preview_composition_token_inspector_line_id(index: i32) NodeKey { + return node_key(20801 + index as i64); +} +fn linux_preview_composition_typography_id() NodeKey { + return node_key(30000); +} +fn linux_preview_composition_components_viewport_id() NodeKey { + return node_key(20900); +} +fn linux_preview_composition_component_gallery_id() NodeKey { + return node_key(40000); +} + +fn linux_preview_composition_component_content_size() Size { + return size(1072 * unit_scale(), 1192 * unit_scale()); +} + +fn linux_preview_typography_bounds(layout: LinuxPreviewLayout) Rect { + if (layout.error != LinuxPreviewLayoutError.none) { return rect(0, 0, 0, 0); } + return layout.route_content; +} + +fn linux_preview_token_inspector_available(layout: LinuxPreviewLayout) i32 { + return (layout.error == LinuxPreviewLayoutError.none && + layout.status_visible != 0 && + layout.status.width > 0 && layout.status.height > 0) as i32; +} + +fn linux_preview_token_inspector_bounds(layout: LinuxPreviewLayout) Rect { + if (linux_preview_token_inspector_available(layout) == 0) { + return rect(0, 0, 0, 0); + } + return layout.status; +} + +fn linux_preview_app_state() LinuxPreviewAppState { + return LinuxPreviewAppState{ + .selected_navigation = linux_preview_initial_navigation(), + .overview_segmented = linux_preview_composition_segmented_model(), + .token_inspector_open = 0, + .interaction = linux_preview_interaction_state(), + .components_scroll = scroll_state( + size(0, 0), linux_preview_composition_component_content_size()), + .gallery_runtime = + component_state_gallery_runtime_state_default(), + }; +} + +fn linux_preview_app_state_valid(state: LinuxPreviewAppState) i32 { + let content: Size = linux_preview_composition_component_content_size(); + if (state.selected_navigation == 1 && + node_key_equal(state.interaction.focused, + hit_root_key()) == 0 && + node_key_equal(state.gallery_runtime.focused, + semantic_none_key()) == 0) { return 0; } + return (linux_preview_navigation_index_valid( + state.selected_navigation) != 0 && + segmented_control_model_valid(state.overview_segmented) != 0 && + state.overview_segmented.segment_count == 4 && + segmented_control_index_enabled(state.overview_segmented, 3) == 0 && + (state.token_inspector_open == 0 || + state.token_inspector_open == 1) && + linux_preview_interaction_state_valid(state.interaction) != 0 && + scroll_state_valid(state.components_scroll) == ScrollError.none && + state.components_scroll.content.width == content.width && + state.components_scroll.content.height == content.height && + component_state_gallery_runtime_state_valid( + linux_preview_composition_component_gallery_id(), + state.gallery_runtime) != 0) as i32; +} + +fn linux_preview_app_state_hash(state: LinuxPreviewAppState) i64 { + let hash: i64 = linux_preview_composition_mix(17, + state.selected_navigation as i64); + hash = linux_preview_composition_mix(hash, + state.overview_segmented.selected_index as i64); + hash = linux_preview_composition_mix(hash, + state.overview_segmented.roving_index as i64); + hash = linux_preview_composition_mix(hash, + state.overview_segmented.has_focus as i64); + hash = linux_preview_composition_mix(hash, + state.overview_segmented.focus_visible as i64); + hash = linux_preview_composition_mix(hash, + state.overview_segmented.hovered_index as i64); + hash = linux_preview_composition_mix(hash, + state.overview_segmented.pressed_index as i64); + hash = linux_preview_composition_mix(hash, + state.overview_segmented.enabled_mask); + hash = linux_preview_composition_mix(hash, + state.token_inspector_open as i64); + hash = linux_preview_composition_mix(hash, state.interaction.hovered.value); + hash = linux_preview_composition_mix(hash, state.interaction.pressed.value); + hash = linux_preview_composition_mix(hash, state.interaction.focused.value); + hash = linux_preview_composition_mix(hash, + state.interaction.focus_visible as i64); + hash = linux_preview_composition_mix(hash, + scroll_state_hash(state.components_scroll)); + hash = linux_preview_composition_mix(hash, + component_state_gallery_runtime_state_hash(state.gallery_runtime)); + if (hash == 0) { return 1; } + return hash; +} + +fn linux_preview_app_state_reconcile_components( + state: *LinuxPreviewAppState, + layout: LinuxPreviewLayout) ScrollMutation { + let anchor: ScrollAnchor = scroll_anchor( + state.*.components_scroll.offset_x, + state.*.components_scroll.offset_y, 0, 0); + return scroll_reconcile(&state.*.components_scroll, + size(layout.route_content.width, layout.route_content.height), + linux_preview_composition_component_content_size(), anchor); +} + +fn linux_preview_composition_components_viewport_spec( + layout: LinuxPreviewLayout, + interaction: LinuxPreviewInteractionState) ScrollViewportSpec { + let id: NodeKey = linux_preview_composition_components_viewport_id(); + let spec: ScrollViewportSpec = scroll_viewport_spec(id, + linux_preview_composition_main_id(), + linux_preview_composition_main_id(), layout.route_content, + "Component gallery viewport"); + spec.description = + "Scrollable canonical component tiers and interaction states"; + spec.value = "Horizontal and vertical gallery position"; + spec.axis = ScrollViewportAxis.both; + spec.density = FlexDensity.standard; + spec.focus_order = 7; + spec.content_focus_order_offset = 100; + spec.z_order = 50; + spec.focused = node_key_equal(interaction.focused, id); + spec.focus_visible = (spec.focused != 0 && + interaction.focus_visible != 0) as i32; + return spec; +} + +fn linux_preview_app_state_scroll_components( + state: *LinuxPreviewAppState, + delta_x: i64, delta_y: i64) ScrollViewportTransition { + return scroll_viewport_apply_delta( + &state.*.components_scroll, ScrollViewportAxis.both, + LayoutDirection.ltr, + (state.*.selected_navigation == 1) as i32, + ScrollViewportInputKind.semantic, + scroll_viewport_command_for_delta(ScrollViewportAxis.both, + delta_x, delta_y), + delta_x, delta_y); +} + +fn linux_preview_app_state_reveal_component( + state: *LinuxPreviewAppState, + artifact: *LinuxPreviewCompositionArtifact, + id: NodeKey) ScrollViewportTargetReveal { + return scroll_viewport_reveal_hosted_semantic_id( + &state.*.components_scroll, ScrollViewportAxis.both, + LayoutDirection.ltr, + (artifact.*.selected_navigation == 1 && + artifact.*.component_viewport_composed != 0) as i32, + &artifact.*.semantics, artifact.*.component_viewport.geometry, + linux_preview_composition_components_viewport_id(), id); +} + +fn linux_preview_composition_typography_fits( + layout: LinuxPreviewLayout) i32 { + let bounds: Rect = linux_preview_typography_bounds(layout); + return (layout.error == LinuxPreviewLayoutError.none && + layout.mode == LinuxPreviewLayoutMode.expanded && + bounds.width >= typography_specimen_structural_minimum_width( + FlexDensity.standard) && + bounds.height >= typography_specimen_required_height( + unit_scale(), FlexDensity.standard)) as i32; +} + +fn linux_preview_composition_navigation_enabled(index: i32, + components_available: i32, typography_available: i32) i32 { + return (index == linux_preview_initial_navigation() || + (index == 1 && components_available != 0) || + (index == 3 && typography_available != 0)) as i32; +} + +fn linux_preview_composition_navigation_normalize(index: i32, + components_available: i32, typography_available: i32) i32 { + if (linux_preview_navigation_index_valid(index) != 0 && + linux_preview_composition_navigation_enabled( + index, components_available, + typography_available) != 0) { return index; } + return linux_preview_initial_navigation(); +} + +fn linux_preview_composition_navigation_value(index: i32, + components_available: i32, typography_available: i32) []u8 { + if (linux_preview_composition_navigation_enabled( + index, components_available, + typography_available) != 0) { return "Available"; } + if (index == 1) { + return "Requires valid layout, font, gallery, and viewport host"; + } + if (index == 3) { return "Requires expanded layout and three font weights"; } + return linux_preview_navigation_value(index); +} + +fn linux_preview_composition_general_target_available( + layout: LinuxPreviewLayout, id: NodeKey, + selected_navigation: i32, components_available: i32, + typography_available: i32) i32 { + if (node_key_equal(id, hit_root_key()) != 0) { return 1; } + let index: i32 = 0; + while (index < linux_preview_navigation_count()) { + if (node_key_equal(id, + linux_preview_composition_nav_id(index)) != 0) { + return linux_preview_composition_navigation_enabled( + index, components_available, typography_available); + } + index = index + 1; + } + if (node_key_equal(id, + linux_preview_composition_inspect_id()) != 0) { + return (selected_navigation == 0 && + linux_preview_token_inspector_available(layout) != 0) as i32; + } + if (node_key_equal(id, + linux_preview_composition_components_viewport_id()) != 0) { + return (selected_navigation == 1 && + components_available != 0) as i32; + } + return 0; +} + +fn linux_preview_composition_diagnostics() + LinuxPreviewCompositionDiagnostics { + return LinuxPreviewCompositionDiagnostics{ + .display_error = DisplayError.none, + .semantics_error = SemanticsError.none, + .hit_error = HitTreeError.none, + .navigation_error = NavigationItemBuildError.none, + .button_error = ButtonBuildError.none, + .segmented_error = SegmentedControlBuildError.none, + .surface_error = SurfaceBuildError.none, + .surface_capacity_error = SurfaceCapacityError.none, + .chart_error = PerformanceChartError.none, + .chart_host_error = PerformanceChartHostError.none, + .typography_error = TypographySpecimenError.none, + .typography_host_error = TypographySpecimenHostError.none, + .gallery_error = ComponentStateGalleryError.none, + .scroll_viewport_error = ScrollViewportBuildError.none, + .scroll_viewport_capacity_error = + ScrollViewportCapacityError.none, + .scroll_viewport_resource_error = RenderResourceError.none, + .text_error = TextError.none, + .text_contribution_error = TextContributionError.none, + }; +} + +fn linux_preview_composition_empty(layout: LinuxPreviewLayout, + state: LinuxPreviewAppState, components_available: i32, + typography_fonts_available: i32) + LinuxPreviewCompositionArtifact { + let normalized_state: LinuxPreviewAppState = state; + let normalized_model: SegmentedControlModel = + normalized_state.overview_segmented; + if (segmented_control_model_valid(normalized_model) == 0 || + normalized_model.segment_count != 4 || + segmented_control_index_enabled(normalized_model, 3) != 0) { + normalized_model = linux_preview_composition_segmented_model_for(0); + } + let normalized_inspector: i32 = + (normalized_state.token_inspector_open != 0 && + normalized_state.selected_navigation == 0 && + linux_preview_token_inspector_available(layout) != 0) as i32; + let typography_available: i32 = + (typography_fonts_available != 0 && + linux_preview_composition_typography_fits(layout) != 0) as i32; + let normalized_navigation: i32 = + linux_preview_composition_navigation_normalize( + normalized_state.selected_navigation, components_available, + typography_available); + normalized_state.selected_navigation = normalized_navigation; + normalized_state.token_inspector_open = normalized_inspector; + normalized_state.overview_segmented = normalized_model; + if (normalized_navigation != linux_preview_initial_navigation()) { + normalized_model.has_focus = 0; + normalized_model.focus_visible = 0; + normalized_model.hovered_index = segmented_control_no_index(); + normalized_model.pressed_index = segmented_control_no_index(); + } + let normalized_interaction: LinuxPreviewInteractionState = + normalized_state.interaction; + if (linux_preview_composition_general_target_available(layout, + normalized_interaction.hovered, normalized_navigation, + components_available, typography_available) == 0) { + normalized_interaction.hovered = hit_root_key(); + } + if (linux_preview_composition_general_target_available(layout, + normalized_interaction.pressed, normalized_navigation, + components_available, typography_available) == 0) { + normalized_interaction.pressed = hit_root_key(); + } + if (linux_preview_composition_general_target_available(layout, + normalized_interaction.focused, normalized_navigation, + components_available, typography_available) == 0) { + normalized_interaction.focused = hit_root_key(); + normalized_interaction.focus_visible = 0; + } + normalized_state.interaction = normalized_interaction; + normalized_state.overview_segmented = normalized_model; + let viewport_spec: ScrollViewportSpec = + linux_preview_composition_components_viewport_spec( + layout, normalized_interaction); + return LinuxPreviewCompositionArtifact{ + .layout = layout, + .display = display_list_make(), + .semantics = semantics_tree_make(), + .hits = hit_tree_make(), + .error = LinuxPreviewCompositionError.none, + .diagnostics = linux_preview_composition_diagnostics(), + .app_state = normalized_state, + .interaction = normalized_interaction, + .selected_navigation = normalized_navigation, + .components_available = components_available, + .typography_available = typography_available, + .segmented_model = normalized_model, + .token_inspector_open = normalized_inspector, + .navigation_items_composed = 0, + .inspect_composed = 0, + .token_inspector_composed = 0, + .token_inspector_text_instances = 0, + .segmented_control_composed = 0, + .segmented_items_composed = 0, + .surfaces_composed = 0, + .chart_composed = 0, + .chart_text_slots_composed = 0, + .typography_composed = 0, + .typography_rows_composed = 0, + .typography_display_operations = 0, + .typography_display_resources = 0, + .typography_semantic_nodes = 0, + .component_gallery_composed = 0, + .component_gallery_display_operations = 0, + .component_gallery_display_resources = 0, + .component_gallery_hit_nodes = 0, + .component_gallery_semantic_nodes = 0, + .component_gallery_evidence_hash = 0, + .component_viewport_composed = 0, + .component_viewport_display_operations = 0, + .component_viewport_display_resources = 0, + .component_viewport_hit_nodes = 0, + .component_viewport_semantic_nodes = 0, + .component_viewport_state_hash = 0, + .component_viewport_evidence_hash = 0, + .component_viewport = scroll_viewport_empty_artifact( + viewport_spec, normalized_state.components_scroll), + .compact_summary_composed = 0, + .unsupported_size_composed = 0, + .status_items_composed = 0, + .text_instances_composed = 0, + .component_evidence_hash = 17, + .evidence_hash = 0, + }; +} + +fn linux_preview_composition_mix(hash: i64, value: i64) i64 { + return (hash * 1099511628211) ^ value; +} + +fn linux_preview_composition_hit_hash(tree: HitTree) i64 { + let hash: i64 = linux_preview_composition_mix(17, tree.revision); + hash = linux_preview_composition_mix(hash, tree.nodes.len as i64); + let index: i32 = 0; + while (index < tree.nodes.len) { + let node: HitNode = tree.nodes.data[index]; + hash = linux_preview_composition_mix(hash, node.id.value); + hash = linux_preview_composition_mix(hash, node.id.generation); + hash = linux_preview_composition_mix(hash, node.parent.value); + hash = linux_preview_composition_mix(hash, node.parent.generation); + hash = linux_preview_composition_mix(hash, node.bounds.x); + hash = linux_preview_composition_mix(hash, node.bounds.y); + hash = linux_preview_composition_mix(hash, node.bounds.width); + hash = linux_preview_composition_mix(hash, node.bounds.height); + hash = linux_preview_composition_mix(hash, node.clip.x); + hash = linux_preview_composition_mix(hash, node.clip.y); + hash = linux_preview_composition_mix(hash, node.clip.width); + hash = linux_preview_composition_mix(hash, node.clip.height); + hash = linux_preview_composition_mix(hash, node.has_clip as i64); + hash = linux_preview_composition_mix(hash, node.z_order); + hash = linux_preview_composition_mix(hash, node.enabled as i64); + hash = linux_preview_composition_mix(hash, node.focusable as i64); + index = index + 1; + } + return hash; +} + +fn linux_preview_composition_hash( + artifact: LinuxPreviewCompositionArtifact) i64 { + let hash: i64 = linux_preview_composition_mix(17, + artifact.layout.mode as i64); + hash = linux_preview_composition_mix(hash, artifact.layout.error as i64); + hash = linux_preview_composition_mix(hash, artifact.layout.viewport.width); + hash = linux_preview_composition_mix(hash, artifact.layout.viewport.height); + hash = linux_preview_composition_mix(hash, artifact.layout.navigation.x); + hash = linux_preview_composition_mix(hash, artifact.layout.navigation.y); + hash = linux_preview_composition_mix(hash, artifact.layout.navigation.width); + hash = linux_preview_composition_mix(hash, artifact.layout.navigation.height); + hash = linux_preview_composition_mix(hash, artifact.layout.main.x); + hash = linux_preview_composition_mix(hash, artifact.layout.main.y); + hash = linux_preview_composition_mix(hash, artifact.layout.main.width); + hash = linux_preview_composition_mix(hash, artifact.layout.main.height); + hash = linux_preview_composition_mix(hash, + artifact.layout.route_content.x); + hash = linux_preview_composition_mix(hash, + artifact.layout.route_content.y); + hash = linux_preview_composition_mix(hash, + artifact.layout.route_content.width); + hash = linux_preview_composition_mix(hash, + artifact.layout.route_content.height); + hash = linux_preview_composition_mix(hash, artifact.layout.status_visible as i64); + hash = linux_preview_composition_mix(hash, + artifact.layout.status_heading.y); + hash = linux_preview_composition_mix(hash, + artifact.layout.status_heading.height); + hash = linux_preview_composition_mix(hash, + artifact.layout.status_list.y); + hash = linux_preview_composition_mix(hash, + artifact.layout.status_list.height); + hash = linux_preview_composition_mix(hash, artifact.layout.chart_visible as i64); + hash = linux_preview_composition_mix(hash, artifact.selected_navigation as i64); + hash = linux_preview_composition_mix(hash, + artifact.components_available as i64); + hash = linux_preview_composition_mix(hash, + artifact.typography_available as i64); + hash = linux_preview_composition_mix(hash, + artifact.interaction.hovered.value); + hash = linux_preview_composition_mix(hash, + artifact.interaction.hovered.generation); + hash = linux_preview_composition_mix(hash, + artifact.interaction.pressed.value); + hash = linux_preview_composition_mix(hash, + artifact.interaction.pressed.generation); + hash = linux_preview_composition_mix(hash, + artifact.interaction.focused.value); + hash = linux_preview_composition_mix(hash, + artifact.interaction.focused.generation); + hash = linux_preview_composition_mix(hash, + artifact.interaction.focus_visible as i64); + hash = linux_preview_composition_mix(hash, + artifact.segmented_model.segment_count as i64); + hash = linux_preview_composition_mix(hash, + artifact.segmented_model.selected_index as i64); + hash = linux_preview_composition_mix(hash, + artifact.segmented_model.roving_index as i64); + hash = linux_preview_composition_mix(hash, + artifact.segmented_model.has_focus as i64); + hash = linux_preview_composition_mix(hash, + artifact.segmented_model.focus_visible as i64); + hash = linux_preview_composition_mix(hash, + artifact.segmented_model.hovered_index as i64); + hash = linux_preview_composition_mix(hash, + artifact.segmented_model.pressed_index as i64); + hash = linux_preview_composition_mix(hash, + artifact.segmented_model.enabled_mask); + hash = linux_preview_composition_mix(hash, + artifact.token_inspector_open as i64); + hash = linux_preview_composition_mix(hash, + artifact.navigation_items_composed as i64); + hash = linux_preview_composition_mix(hash, artifact.inspect_composed as i64); + hash = linux_preview_composition_mix(hash, + artifact.token_inspector_composed as i64); + hash = linux_preview_composition_mix(hash, + artifact.token_inspector_text_instances as i64); + hash = linux_preview_composition_mix(hash, + artifact.segmented_control_composed as i64); + hash = linux_preview_composition_mix(hash, + artifact.segmented_items_composed as i64); + hash = linux_preview_composition_mix(hash, + artifact.surfaces_composed as i64); + hash = linux_preview_composition_mix(hash, artifact.chart_composed as i64); + hash = linux_preview_composition_mix(hash, + artifact.chart_text_slots_composed as i64); + hash = linux_preview_composition_mix(hash, + artifact.typography_composed as i64); + hash = linux_preview_composition_mix(hash, + artifact.typography_rows_composed as i64); + hash = linux_preview_composition_mix(hash, + artifact.typography_display_operations as i64); + hash = linux_preview_composition_mix(hash, + artifact.typography_display_resources as i64); + hash = linux_preview_composition_mix(hash, + artifact.typography_semantic_nodes as i64); + hash = linux_preview_composition_mix(hash, + artifact.component_gallery_composed as i64); + hash = linux_preview_composition_mix(hash, + artifact.component_gallery_display_operations as i64); + hash = linux_preview_composition_mix(hash, + artifact.component_gallery_display_resources as i64); + hash = linux_preview_composition_mix(hash, + artifact.component_gallery_hit_nodes as i64); + hash = linux_preview_composition_mix(hash, + artifact.component_gallery_semantic_nodes as i64); + hash = linux_preview_composition_mix(hash, + artifact.component_gallery_evidence_hash); + hash = linux_preview_composition_mix(hash, + artifact.component_viewport_composed as i64); + hash = linux_preview_composition_mix(hash, + artifact.component_viewport_display_operations as i64); + hash = linux_preview_composition_mix(hash, + artifact.component_viewport_display_resources as i64); + hash = linux_preview_composition_mix(hash, + artifact.component_viewport_hit_nodes as i64); + hash = linux_preview_composition_mix(hash, + artifact.component_viewport_semantic_nodes as i64); + hash = linux_preview_composition_mix(hash, + artifact.component_viewport_state_hash); + hash = linux_preview_composition_mix(hash, + artifact.component_viewport_evidence_hash); + hash = linux_preview_composition_mix(hash, + artifact.compact_summary_composed as i64); + hash = linux_preview_composition_mix(hash, + artifact.unsupported_size_composed as i64); + hash = linux_preview_composition_mix(hash, + artifact.status_items_composed as i64); + hash = linux_preview_composition_mix(hash, + artifact.text_instances_composed as i64); + hash = linux_preview_composition_mix(hash, artifact.component_evidence_hash); + hash = linux_preview_composition_mix(hash, + linux_preview_app_state_hash(artifact.app_state)); + hash = linux_preview_composition_mix(hash, artifact.display.content_hash); + hash = linux_preview_composition_mix(hash, + talkback_tree_hash(artifact.semantics)); + hash = linux_preview_composition_mix(hash, + linux_preview_composition_hit_hash(artifact.hits)); + if (hash == 0) { return 1; } + return hash; +} + +fn linux_preview_composition_push_op( + artifact: *LinuxPreviewCompositionArtifact, + op: DisplayOp) i32 { + let error: DisplayError = display_list_push(&artifact.*.display, op); + if (error != DisplayError.none) { + artifact.*.error = LinuxPreviewCompositionError.display_error; + artifact.*.diagnostics.display_error = error; + return 0; + } + return 1; +} + +fn linux_preview_composition_push_round( + artifact: *LinuxPreviewCompositionArtifact, + id: NodeKey, bounds: Rect, radius: i64, paint: Paint) i32 { + let op: DisplayOp = display_op(DisplayOpKind.fill_rounded_rect, id); + op.bounds = bounds; + op.parameter = radius; + op.paint = paint; + return linux_preview_composition_push_op(artifact, op); +} + +fn linux_preview_composition_surface_group( + artifact: *LinuxPreviewCompositionArtifact, + id: NodeKey, parent: NodeKey, bounds: Rect, + name: []u8, description: []u8, + tier: SurfaceTier, z_order: i64) SurfaceBuildResult { + let spec: SurfaceSpec = surface_spec(id, parent, bounds, name, tier); + spec.description = description; + spec.z_order = z_order; + let result: SurfaceBuildResult = surface_emit(spec, + &artifact.*.display, &artifact.*.hits, &artifact.*.semantics); + if (result.error != SurfaceBuildError.none) { + artifact.*.error = LinuxPreviewCompositionError.surface_error; + artifact.*.diagnostics.surface_error = result.error; + artifact.*.diagnostics.surface_capacity_error = + result.capacity_error; + artifact.*.diagnostics.display_error = result.display_error; + artifact.*.diagnostics.hit_error = result.hit_error; + artifact.*.diagnostics.semantics_error = result.semantics_error; + return result; + } + artifact.*.component_evidence_hash = linux_preview_composition_mix( + artifact.*.component_evidence_hash, + result.artifact.evidence_hash); + artifact.*.surfaces_composed = artifact.*.surfaces_composed + 1; + return result; +} + +fn linux_preview_composition_add_semantic( + artifact: *LinuxPreviewCompositionArtifact, + spec: SemanticsSpec) i32 { + let error: SemanticsError = semantics_add(&artifact.*.semantics, spec); + if (error != SemanticsError.none) { + artifact.*.error = LinuxPreviewCompositionError.semantics_error; + artifact.*.diagnostics.semantics_error = error; + return 0; + } + return 1; +} + +fn linux_preview_composition_add_hit_parent( + artifact: *LinuxPreviewCompositionArtifact, + id: NodeKey, parent: NodeKey, bounds: Rect) i32 { + let node: HitNode = hit_node(id, parent, bounds); + node.clip = bounds; + node.has_clip = 1; + node.enabled = 0; + let error: HitTreeError = hit_tree_add(&artifact.*.hits, node); + if (error != HitTreeError.none) { + artifact.*.error = LinuxPreviewCompositionError.hit_error; + artifact.*.diagnostics.hit_error = error; + return 0; + } + return 1; +} + +fn linux_preview_composition_add_group( + artifact: *LinuxPreviewCompositionArtifact, + id: NodeKey, parent: NodeKey, hit_parent: NodeKey, + role: SemanticRole, name: []u8, description: []u8, + bounds: Rect, add_hit_parent: i32) i32 { + let semantic: SemanticsSpec = semantics_spec(id, parent, role, name); + semantic.description = description; + semantic.has_bounds = 1; + semantic.bounds = bounds; + if (linux_preview_composition_add_semantic(artifact, semantic) == 0) { + return 0; + } + if (add_hit_parent != 0) { + return linux_preview_composition_add_hit_parent( + artifact, id, hit_parent, bounds); + } + return 1; +} + +fn linux_preview_composition_add_text_visual( + artifact: *LinuxPreviewCompositionArtifact, + face: OpenTypeFace, id: NodeKey, semantic_parent: NodeKey, + bounds: Rect, text: []u8, type_token: SemanticTypeToken, + color_token: SemanticColorToken, alignment: TextAlignment, + disabled: i32) i32 { + let spec: TextSpec = text_spec(id, semantic_parent, bounds, text, + type_token, color_token); + spec.alignment = alignment; + spec.overflow = TextOverflowPolicy.clip; + spec.semantics_mode = TextSemanticsMode.parent_owned; + spec.disabled = disabled; + let text_artifact: TextArtifact = text_build(face, spec); + if (text_artifact.error != TextError.none) { + artifact.*.error = LinuxPreviewCompositionError.text_error; + artifact.*.diagnostics.text_error = text_artifact.error; + text_free(&text_artifact); + return 0; + } + let contribution: TextContributionResult = text_contribute( + &text_artifact, &artifact.*.display, &artifact.*.semantics); + if (contribution.error != TextContributionError.none) { + artifact.*.error = LinuxPreviewCompositionError.text_contribution_error; + artifact.*.diagnostics.text_contribution_error = contribution.error; + artifact.*.diagnostics.display_error = contribution.display_error; + artifact.*.diagnostics.semantics_error = contribution.semantics_error; + text_free(&text_artifact); + return 0; + } + artifact.*.component_evidence_hash = linux_preview_composition_mix( + artifact.*.component_evidence_hash, text_artifact.evidence_hash); + artifact.*.text_instances_composed = + artifact.*.text_instances_composed + 1; + text_free(&text_artifact); + return 1; +} + +fn linux_preview_composition_add_static_text( + artifact: *LinuxPreviewCompositionArtifact, + face: OpenTypeFace, id: NodeKey, parent: NodeKey, + bounds: Rect, name: []u8, description: []u8, value: []u8, + type_token: SemanticTypeToken, color_token: SemanticColorToken, + alignment: TextAlignment) i32 { + let semantic: SemanticsSpec = semantics_spec( + id, parent, SemanticRole.text, name); + semantic.description = description; + semantic.value = value; + semantic.has_bounds = 1; + semantic.bounds = bounds; + if (linux_preview_composition_add_semantic(artifact, semantic) == 0) { + return 0; + } + return linux_preview_composition_add_text_visual(artifact, face, + id, parent, bounds, name, type_token, color_token, alignment, 0); +} + +fn linux_preview_composition_backdrop( + artifact: *LinuxPreviewCompositionArtifact) i32 { + // Until the retained gradient operation lands, use enough deterministic + // bands that the CPU oracle does not turn the ambient backdrop into four + // visibly stepped stripes. The token resolver uses this same 48-sample + // fallback in its conformance test. + // + // Material choice: the backdrop uses SemanticMaterialToken.veil because + // semantic_material_band_paint only reads the tint_alpha_start/end + // gradient range. veil has tint_alpha 0→12000, producing visible + // per-band variation. solid has tint_alpha 0→0 (no gradient), which + // would make all 48 bands identical and fail the conformance check. + // The fill, edge, radius, and elevation tokens of the material are not + // consumed by the band painter — only the tint gradient matters here. + let band_count: i32 = 48; + let band_height: i64 = artifact.*.layout.viewport.height / + (band_count as i64); + let index: i32 = 0; + while (index < band_count) { + let y: i64 = band_height * (index as i64); + let height: i64 = band_height; + if (index == band_count - 1) { + height = artifact.*.layout.viewport.height - y; + } + let op: DisplayOp = display_op(DisplayOpKind.fill_rect, + linux_preview_composition_root_id()); + op.bounds = rect(0, y, artifact.*.layout.viewport.width, height); + op.paint = semantic_material_band_paint( + SemanticMaterialToken.veil, index, band_count); + if (linux_preview_composition_push_op(artifact, op) == 0) { return 0; } + index = index + 1; + } + return 1; +} + +fn linux_preview_composition_header( + artifact: *LinuxPreviewCompositionArtifact, + face: OpenTypeFace) i32 { + let surface: SurfaceBuildResult = + linux_preview_composition_surface_group(artifact, + linux_preview_composition_header_id(), + linux_preview_composition_root_id(), artifact.*.layout.header, + "Showcase header", + "Zagkit experimental Linux composition status", + SurfaceTier.base, 10); + if (surface.error != SurfaceBuildError.none) { return 0; } + let title_x: i64 = artifact.*.layout.header.x + + artifact.*.layout.panel_padding; + let title_right: i64 = artifact.*.layout.header_status.x - + artifact.*.layout.content_gap; + let title_bounds: Rect = rect(title_x, artifact.*.layout.header.y, + title_right - title_x, artifact.*.layout.header.height); + if (linux_preview_composition_add_static_text(artifact, face, + linux_preview_composition_header_title_id(), + linux_preview_composition_header_id(), title_bounds, + "Zagkit Studio", "First-party design-system composition", "", + SemanticTypeToken.display, SemanticColorToken.text_primary, + TextAlignment.start) == 0) { return 0; } + return linux_preview_composition_add_static_text(artifact, face, + linux_preview_composition_header_status_id(), + linux_preview_composition_header_id(), + artifact.*.layout.header_status, + "Linux preview - experimental", + "CPU renderer is the visual oracle", "Experimental", + SemanticTypeToken.caption, SemanticColorToken.status_experimental, + TextAlignment.center); +} + +fn linux_preview_composition_navigation( + artifact: *LinuxPreviewCompositionArtifact, + face: OpenTypeFace) i32 { + let surface: SurfaceBuildResult = + linux_preview_composition_surface_group(artifact, + linux_preview_composition_navigation_surface_id(), + linux_preview_composition_root_id(), + artifact.*.layout.navigation, + "Navigation surface", "Canonical primary navigation container", + SurfaceTier.panel, 20); + if (surface.error != SurfaceBuildError.none) { return 0; } + if (linux_preview_composition_add_group(artifact, + linux_preview_composition_navigation_id(), + linux_preview_composition_navigation_surface_id(), + linux_preview_composition_navigation_surface_id(), + SemanticRole.tab_list, + "Primary navigation", + "Only routes with composed native content are enabled", + surface.artifact.content_bounds, 1) == 0) { return 0; } + let index: i32 = 0; + while (index < linux_preview_navigation_count()) { + let id: NodeKey = linux_preview_composition_nav_id(index); + let spec: NavigationItemSpec = navigation_item_spec(id, + linux_preview_composition_navigation_id(), + linux_preview_navigation_item_bounds(artifact.*.layout, index, + linux_preview_navigation_count()), + linux_preview_navigation_name(index)); + spec.description = linux_preview_composition_navigation_value( + index, artifact.*.components_available, + artifact.*.typography_available); + spec.z_order = 40 + index as i64; + spec.set_size = linux_preview_navigation_count(); + spec.position_in_set = index + 1; + let enabled: i32 = linux_preview_composition_navigation_enabled( + index, artifact.*.components_available, + artifact.*.typography_available); + spec.interaction = linux_preview_interaction_for( + artifact.*.interaction, id, enabled, + (index == artifact.*.selected_navigation) as i32); + if (spec.interaction.enabled != 0) { spec.focus_order = index + 1; } + let result: NavigationItemBuildResult = navigation_item_emit( + spec, &artifact.*.display, &artifact.*.hits, + &artifact.*.semantics); + if (result.error != NavigationItemBuildError.none) { + artifact.*.error = LinuxPreviewCompositionError.navigation_error; + artifact.*.diagnostics.navigation_error = result.error; + artifact.*.diagnostics.display_error = result.display_error; + artifact.*.diagnostics.hit_error = result.hit_error; + artifact.*.diagnostics.semantics_error = result.semantics_error; + return 0; + } + artifact.*.component_evidence_hash = linux_preview_composition_mix( + artifact.*.component_evidence_hash, + result.artifact.evidence_hash); + artifact.*.navigation_items_composed = + artifact.*.navigation_items_composed + 1; + if (linux_preview_composition_add_text_visual(artifact, face, id, + linux_preview_composition_navigation_id(), + result.artifact.label_content_bounds, + linux_preview_navigation_name(index), + result.artifact.label_type_token, + result.artifact.label_color_token, TextAlignment.start, + (result.artifact.effectively_enabled == 0) as i32) == 0) { + return 0; + } + index = index + 1; + } + return 1; +} + +fn linux_preview_composition_inspect( + artifact: *LinuxPreviewCompositionArtifact, + face: OpenTypeFace) i32 { + let available: i32 = linux_preview_token_inspector_available( + artifact.*.layout); + let label: []u8 = "Inspect tokens"; + if (available == 0) { label = "Unavailable"; } + else if (artifact.*.token_inspector_open != 0) { + label = "Close inspector"; + } + let spec: ButtonSpec = button_spec( + linux_preview_composition_inspect_id(), + linux_preview_composition_main_id(), + linux_preview_inspect_bounds(artifact.*.layout), label); + spec.description = + "Toggle exact semantic token provenance for the selected main surface"; + spec.focus_order = 0; + spec.z_order = 70; + spec.variant = ButtonVariant.primary; + spec.interaction = linux_preview_interaction_for( + artifact.*.interaction, spec.id, available, + artifact.*.token_inspector_open); + if (available != 0) { spec.focus_order = 7; } + let result: ButtonBuildResult = button_emit(spec, + &artifact.*.display, &artifact.*.hits, &artifact.*.semantics); + if (result.error != ButtonBuildError.none) { + artifact.*.error = LinuxPreviewCompositionError.button_error; + artifact.*.diagnostics.button_error = result.error; + artifact.*.diagnostics.display_error = result.display_error; + artifact.*.diagnostics.hit_error = result.hit_error; + artifact.*.diagnostics.semantics_error = result.semantics_error; + return 0; + } + artifact.*.component_evidence_hash = linux_preview_composition_mix( + artifact.*.component_evidence_hash, result.artifact.evidence_hash); + artifact.*.inspect_composed = 1; + return linux_preview_composition_add_text_visual(artifact, face, + spec.id, spec.parent, result.artifact.content_bounds, spec.label, + SemanticTypeToken.label, result.artifact.style.text_token, + TextAlignment.center, (available == 0) as i32); +} + +fn linux_preview_composition_segment_name(index: i32) []u8 { + if (index == 1) { return "Layout"; } + if (index == 2) { return "Motion"; } + if (index == 3) { return "Render"; } + return "State"; +} + +fn linux_preview_composition_segment_description(index: i32) []u8 { + if (index == 1) { return "Flex layout and placement evidence"; } + if (index == 2) { return "Interruptible motion continuity evidence"; } + if (index == 3) { return "Renderer transport evidence is unavailable"; } + return "Declarative state dependency evidence"; +} + +fn linux_preview_composition_segmented_model_for( + selected_segment: i32) SegmentedControlModel { + let selected: i32 = selected_segment; + if (selected < 0 || selected > 2) { selected = 0; } + let model: SegmentedControlModel = segmented_control_model(4, selected); + // Resting native UI reports only retained truth. Hover and focus are + // supplied by actual X11 events; Render remains explicitly unavailable. + model.enabled_mask = model.enabled_mask & + (0 - 1 - segmented_control_bit(3)); + return model; +} + +fn linux_preview_composition_segmented_model() SegmentedControlModel { + return linux_preview_composition_segmented_model_for(0); +} + +fn linux_preview_composition_segmented_spec_for(layout: LinuxPreviewLayout, + selected_segment: i32) + SegmentedControlSpec { + let spec: SegmentedControlSpec = segmented_control_spec( + linux_preview_composition_segmented_id(), + linux_preview_composition_main_id(), layout.segmented_control, + "Pipeline stage", + linux_preview_composition_segmented_model_for(selected_segment)); + spec.description = + "Select the evidence stage displayed by the performance chart"; + spec.focus_order_base = 8; + spec.z_order = 60; + spec.purpose = SegmentedControlPurpose.view_switcher; + return spec; +} + +fn linux_preview_composition_segmented_spec(layout: LinuxPreviewLayout) + SegmentedControlSpec { + return linux_preview_composition_segmented_spec_for(layout, 0); +} + +fn linux_preview_composition_segments( + artifact: *LinuxPreviewCompositionArtifact, + face: OpenTypeFace) i32 { + if (artifact.*.layout.segmented_control_visible == 0) { return 1; } + let options: ArrayList[SegmentedControlOption] = + make[SegmentedControlOption](4); + let index: i32 = 0; + while (index < 4) { + push[SegmentedControlOption](&options, SegmentedControlOption{ + .id = linux_preview_composition_segment_id(index), + .label = linux_preview_composition_segment_name(index), + .description = + linux_preview_composition_segment_description(index), + }); + index = index + 1; + } + let spec: SegmentedControlSpec = + linux_preview_composition_segmented_spec_for( + artifact.*.layout, + artifact.*.segmented_model.selected_index); + spec.model = artifact.*.segmented_model; + let result: SegmentedControlBuildResult = segmented_control_emit( + spec, &options, &artifact.*.display, &artifact.*.hits, + &artifact.*.semantics); + if (result.error != SegmentedControlBuildError.none) { + artifact.*.error = LinuxPreviewCompositionError.segmented_error; + artifact.*.diagnostics.segmented_error = result.error; + artifact.*.diagnostics.display_error = result.display_error; + artifact.*.diagnostics.hit_error = result.hit_error; + artifact.*.diagnostics.semantics_error = result.semantics_error; + free[SegmentedControlOption](&options); + return 0; + } + artifact.*.component_evidence_hash = linux_preview_composition_mix( + artifact.*.component_evidence_hash, result.artifact.evidence_hash); + artifact.*.segmented_control_composed = 1; + artifact.*.segmented_items_composed = 4; + index = 0; + while (index < 4) { + let style: SegmentedControlResolvedStyle = + segmented_control_style(spec, index); + if (linux_preview_composition_add_text_visual(artifact, face, + linux_preview_composition_segment_id(index), + linux_preview_composition_segmented_id(), + segmented_control_content_bounds(spec, index), + linux_preview_composition_segment_name(index), + result.artifact.label_type_token, style.label_color_token, + TextAlignment.center, + (segmented_control_index_enabled(spec.model, index) == 0) as i32) == 0) { + free[SegmentedControlOption](&options); + return 0; + } + index = index + 1; + } + free[SegmentedControlOption](&options); + return 1; +} + +fn linux_preview_composition_chart_points(y0: i64, y1: i64, + y2: i64, y3: i64, t0: []u8, t1: []u8, t2: []u8, + t3: []u8) ArrayList[PerformanceChartPoint] { + let points: ArrayList[PerformanceChartPoint] = + make[PerformanceChartPoint](4); + push[PerformanceChartPoint](&points, + performance_chart_point(0, y0, "0 s", t0)); + push[PerformanceChartPoint](&points, + performance_chart_point(10, y1, "10 s", t1)); + push[PerformanceChartPoint](&points, + performance_chart_point(20, y2, "20 s", t2)); + push[PerformanceChartPoint](&points, + performance_chart_point(30, y3, "30 s", t3)); + return points; +} + +fn linux_preview_composition_chart( + artifact: *LinuxPreviewCompositionArtifact, + face: OpenTypeFace) i32 { + if (artifact.*.layout.chart_visible == 0) { return 1; } + let state_points: ArrayList[PerformanceChartPoint] = + linux_preview_composition_chart_points( + 3, 4, 3, 5, "3 ms", "4 ms", "3 ms", "5 ms"); + let layout_points: ArrayList[PerformanceChartPoint] = + linux_preview_composition_chart_points( + 9, 8, 10, 9, "9 ms", "8 ms", "10 ms", "9 ms"); + let motion_points: ArrayList[PerformanceChartPoint] = + linux_preview_composition_chart_points( + 6, 7, 5, 6, "6 ms", "7 ms", "5 ms", "6 ms"); + let series: ArrayList[PerformanceChartSeries] = + make[PerformanceChartSeries](3); + let x_ticks: ArrayList[[]u8] = make[[]u8](7); + push[[]u8](&x_ticks, "0 s"); push[[]u8](&x_ticks, "5 s"); + push[[]u8](&x_ticks, "10 s"); push[[]u8](&x_ticks, "15 s"); + push[[]u8](&x_ticks, "20 s"); push[[]u8](&x_ticks, "25 s"); + push[[]u8](&x_ticks, "30 s"); + let y_ticks: ArrayList[[]u8] = make[[]u8](7); + push[[]u8](&y_ticks, "24 ms"); push[[]u8](&y_ticks, "20 ms"); + push[[]u8](&y_ticks, "16 ms"); push[[]u8](&y_ticks, "12 ms"); + push[[]u8](&y_ticks, "8 ms"); push[[]u8](&y_ticks, "4 ms"); + push[[]u8](&y_ticks, "0 ms"); + + let state_series: PerformanceChartSeries = performance_chart_series_role( + linux_preview_composition_chart_series_id(0), "State propagation", + state_points, + PerformanceChartSeriesToken.reference); + state_series.description = "Deterministic state dependency fixture"; + state_series.selected = + (artifact.*.segmented_model.selected_index == 0) as i32; + push[PerformanceChartSeries](&series, state_series); + let layout_series: PerformanceChartSeries = performance_chart_series_role( + linux_preview_composition_chart_series_id(1), "Layout and paint", + layout_points, + PerformanceChartSeriesToken.comparison); + layout_series.description = "Deterministic Flex and paint fixture"; + layout_series.selected = + (artifact.*.segmented_model.selected_index == 1) as i32; + push[PerformanceChartSeries](&series, layout_series); + let motion_series: PerformanceChartSeries = performance_chart_series_role( + linux_preview_composition_chart_series_id(2), + "Motion scheduler", motion_points, + PerformanceChartSeriesToken.diagnostic); + motion_series.description = "Deterministic spring scheduling fixture"; + motion_series.selected = + (artifact.*.segmented_model.selected_index == 2) as i32; + push[PerformanceChartSeries](&series, motion_series); + + let spec: PerformanceChartSpec = performance_chart_spec( + linux_preview_composition_chart_id(), artifact.*.layout.chart, + series, x_ticks, y_ticks); + spec.title = "Pipeline frame-cost fixture"; + spec.description = + "Deterministic conformance data; not measured benchmark results"; + spec.x_axis_name = "Elapsed time"; + spec.x_axis_unit = "seconds"; + spec.y_axis_name = "Frame time"; + spec.y_axis_unit = "milliseconds"; + spec.x_min = 0; spec.x_max = 30; + spec.y_min = 0; spec.y_max = 24; + spec.x_tick_count = 7; spec.y_tick_count = 7; + spec.baseline_value = 8; + spec.baseline_name = "Eight millisecond review baseline"; + spec.deadline_value = 16; + spec.deadline_name = "Sixteen millisecond deadline"; + let chart: PerformanceChartArtifact = performance_chart_build(spec); + + free[PerformanceChartSeries](&series); + free[PerformanceChartPoint](&motion_points); + free[PerformanceChartPoint](&layout_points); + free[PerformanceChartPoint](&state_points); + free[[]u8](&y_ticks); + free[[]u8](&x_ticks); + + if (chart.error != PerformanceChartError.none || + chart.text_slots.len != 24) { + artifact.*.error = LinuxPreviewCompositionError.chart_error; + artifact.*.diagnostics.chart_error = chart.error; + performance_chart_free(&chart); + return 0; + } + let hosted: PerformanceChartHostResult = + performance_chart_contribute_hosted(&chart, + performance_chart_host_spec( + linux_preview_composition_main_id(), + linux_preview_composition_main_id(), 20), + &artifact.*.display, &artifact.*.semantics, + &artifact.*.hits); + if (hosted.error != PerformanceChartHostError.none) { + artifact.*.error = LinuxPreviewCompositionError.chart_host_error; + artifact.*.diagnostics.chart_host_error = hosted.error; + artifact.*.diagnostics.display_error = hosted.display_error; + artifact.*.diagnostics.semantics_error = hosted.semantics_error; + artifact.*.diagnostics.hit_error = hosted.hit_error; + performance_chart_free(&chart); + return 0; + } + artifact.*.chart_composed = 1; + artifact.*.component_evidence_hash = linux_preview_composition_mix( + artifact.*.component_evidence_hash, chart.evidence_hash); + let slot_index: i32 = 0; + while (slot_index < chart.text_slots.len) { + let slot: PerformanceChartTextSlot = chart.text_slots.data[slot_index]; + if (linux_preview_composition_add_text_visual(artifact, face, + slot.id, chart.canvas.key, slot.bounds, + slot.text.data[0..slot.text.len], slot.type_token, + slot.color_token, TextAlignment.start, 0) == 0) { + performance_chart_free(&chart); + return 0; + } + artifact.*.chart_text_slots_composed = + artifact.*.chart_text_slots_composed + 1; + slot_index = slot_index + 1; + } + performance_chart_free(&chart); + return 1; +} + +fn linux_preview_composition_compact_summary( + artifact: *LinuxPreviewCompositionArtifact, + face: OpenTypeFace) i32 { + if (artifact.*.layout.chart_visible != 0) { return 1; } + let id: NodeKey = linux_preview_composition_summary_id(); + let surface: SurfaceBuildResult = + linux_preview_composition_surface_group(artifact, + linux_preview_composition_summary_surface_id(), + linux_preview_composition_main_id(), + artifact.*.layout.compact_summary, + "Compact performance surface", + "Accessible summary used when the chart cannot fit", + SurfaceTier.panel, 30); + if (surface.error != SurfaceBuildError.none) { return 0; } + let summary: SemanticsSpec = semantics_spec(id, + linux_preview_composition_summary_surface_id(), SemanticRole.text, + "Frame pacing summary"); + summary.description = + "The full chart is unavailable because its accessible minimum size does not fit"; + let visible_value: []u8 = "State peak 5 ms · deadline 16 ms"; + summary.value = "Selected fixture peak 5 milliseconds; deadline 16 milliseconds"; + if (artifact.*.segmented_model.selected_index == 1) { + visible_value = "Layout peak 10 ms · deadline 16 ms"; + summary.value = + "Selected fixture peak 10 milliseconds; deadline 16 milliseconds"; + } else if (artifact.*.segmented_model.selected_index == 2) { + visible_value = "Motion peak 7 ms · deadline 16 ms"; + summary.value = + "Selected fixture peak 7 milliseconds; deadline 16 milliseconds"; + } + summary.has_bounds = 1; + summary.bounds = artifact.*.layout.compact_summary; + if (linux_preview_composition_add_semantic(artifact, summary) == 0) { + return 0; + } + let inset: i64 = artifact.*.layout.panel_padding; + let line_height: i64 = flex_spacing( + FlexSpacingToken.xxlarge, FlexDensity.standard); + let title_bounds: Rect = rect(summary.bounds.x + inset, + summary.bounds.y + inset, + summary.bounds.width - inset * 2, line_height); + let value_bounds: Rect = rect(title_bounds.x, + title_bounds.y + line_height + artifact.*.layout.section_gap, + title_bounds.width, line_height); + if (linux_preview_composition_add_text_visual(artifact, face, id, + linux_preview_composition_summary_surface_id(), title_bounds, + "Frame chart unavailable at this width", SemanticTypeToken.heading, + SemanticColorToken.text_primary, TextAlignment.start, 0) == 0) { + return 0; + } + if (linux_preview_composition_add_text_visual(artifact, face, + semantic_generated_key(id, SemanticRole.text, 1), id, + value_bounds, visible_value, + SemanticTypeToken.body, SemanticColorToken.text_secondary, + TextAlignment.start, 0) == 0) { return 0; } + artifact.*.compact_summary_composed = 1; + return 1; +} + +fn linux_preview_composition_status( + artifact: *LinuxPreviewCompositionArtifact, + face: OpenTypeFace) i32 { + if (artifact.*.layout.status_visible == 0) { return 1; } + let surface: SurfaceBuildResult = + linux_preview_composition_surface_group(artifact, + linux_preview_composition_status_surface_id(), + linux_preview_composition_root_id(), artifact.*.layout.status, + "Status surface", "Read-only system evidence container", + SurfaceTier.panel, 20); + if (surface.error != SurfaceBuildError.none) { return 0; } + let heading_height: i64 = flex_spacing( + FlexSpacingToken.xlarge, FlexDensity.standard); + let caption_top: i64 = heading_height + flex_spacing( + FlexSpacingToken.tiny, FlexDensity.standard); + if (linux_preview_composition_add_static_text(artifact, face, + linux_preview_composition_status_heading_id(), + linux_preview_composition_status_surface_id(), + rect(artifact.*.layout.status_heading.x, + artifact.*.layout.status_heading.y, + artifact.*.layout.status_heading.width, heading_height), + "System status", "Read-only evidence rail", "", + SemanticTypeToken.heading, SemanticColorToken.text_primary, + TextAlignment.start) == 0) { return 0; } + if (linux_preview_composition_add_static_text(artifact, face, + linux_preview_composition_status_caption_id(), + linux_preview_composition_status_surface_id(), + rect(artifact.*.layout.status_heading.x, + artifact.*.layout.status_heading.y + caption_top, + artifact.*.layout.status_heading.width, + artifact.*.layout.status_heading.height - caption_top), + "Read-only evidence", "Contains no navigation actions", "", + SemanticTypeToken.caption, SemanticColorToken.text_secondary, + TextAlignment.start) == 0) { return 0; } + let group: SemanticsSpec = semantics_spec( + linux_preview_composition_status_id(), + linux_preview_composition_status_surface_id(), SemanticRole.list, + "System status"); + group.description = "Read-only runtime capability and conformance status"; + group.row_count = 5; + group.column_count = 1; + group.has_bounds = 1; + group.bounds = artifact.*.layout.status_list; + if (linux_preview_composition_add_semantic(artifact, group) == 0) { + return 0; + } + let index: i32 = 0; + while (index < 5) { + let category: ShowcaseCategory = showcase_category_at(index); + let id: NodeKey = linux_preview_composition_status_item_id(index); + let bounds: Rect = linux_preview_status_item_bounds( + artifact.*.layout, index, 5); + if (linux_preview_composition_push_round(artifact, id, bounds, + semantic_radius(SemanticRadiusToken.card), + semantic_color_paint(SemanticColorToken.surface_inset)) == 0) { + return 0; + } + let item: SemanticsSpec = semantics_spec(id, + linux_preview_composition_status_id(), SemanticRole.list_item, + showcase_category_name(category)); + item.description = showcase_category_description(category); + item.value = showcase_category_value(category); + item.row_index = index; + item.column_index = 0; + item.row_span = 1; + item.column_span = 1; + item.has_bounds = 1; + item.bounds = bounds; + if (linux_preview_composition_add_semantic(artifact, item) == 0) { + return 0; + } + // This is a documented category swatch, not an avatar or status dot. + // The adjacent visible category name carries the same semantic role; + // runtime health remains a separate status-token family. + let marker_width: i64 = flex_spacing( + FlexSpacingToken.tiny, FlexDensity.standard); + let marker_height: i64 = flex_spacing( + FlexSpacingToken.xlarge, FlexDensity.standard); + let marker_x: i64 = bounds.x + + flex_spacing(FlexSpacingToken.large, FlexDensity.standard); + let marker_y: i64 = bounds.y + + (bounds.height - marker_height) / 2; + if (linux_preview_composition_push_round(artifact, id, + rect(marker_x, marker_y, marker_width, marker_height), + marker_width / 2, + semantic_color_paint(showcase_category_color(category))) == 0) { + return 0; + } + let text_x: i64 = marker_x + marker_width + + flex_spacing(FlexSpacingToken.small, FlexDensity.standard); + let text_width: i64 = bounds.x + bounds.width - + flex_spacing(FlexSpacingToken.large, FlexDensity.standard) - text_x; + let title_top: i64 = flex_spacing( + FlexSpacingToken.small, FlexDensity.standard) + + flex_spacing(FlexSpacingToken.micro, FlexDensity.standard); + let line_height: i64 = flex_spacing( + FlexSpacingToken.xlarge, FlexDensity.standard); + let line_gap: i64 = flex_spacing( + FlexSpacingToken.tiny, FlexDensity.standard); + let title_bounds: Rect = rect(text_x, + bounds.y + title_top, text_width, line_height); + let value_bounds: Rect = rect(text_x, + bounds.y + title_top + line_height + line_gap, + text_width, line_height); + if (linux_preview_composition_add_text_visual(artifact, face, + id, linux_preview_composition_status_id(), title_bounds, + showcase_category_name(category), SemanticTypeToken.label, + SemanticColorToken.text_primary, TextAlignment.start, 0) == 0) { + return 0; + } + if (linux_preview_composition_add_text_visual(artifact, face, + semantic_generated_key(id, SemanticRole.text, 1), id, + value_bounds, showcase_category_value(category), + SemanticTypeToken.caption, SemanticColorToken.text_secondary, + TextAlignment.start, 0) == 0) { return 0; } + artifact.*.status_items_composed = + artifact.*.status_items_composed + 1; + index = index + 1; + } + return 1; +} + +fn linux_preview_composition_token_inspector_line(index: i32) []u8 { + if (index == 1) { return "Selected NodeKey · 20300:0"; } + if (index == 2) { return "Component · Surface / raised"; } + if (index == 3) { return "Material · material.glass.raised"; } + if (index == 4) { return "Color · color.surface.raised"; } + if (index == 5) { return "Type · type.title + type.body"; } + if (index == 6) { return "Spacing · spacing.xlarge"; } + if (index == 7) { return "Shape · radius.card · elevation.raised"; } + if (index == 8) { return "Environment · dark · 1.0x · standard density"; } + if (index == 9) { + return semantic_material( + SemanticMaterialToken.glass).cpu_fallback_reason; + } + return "Token inspector"; +} + +fn linux_preview_composition_token_inspector_description(index: i32) []u8 { + if (index == 1) { return "Selected retained node identity"; } + if (index == 2) { return "Canonical component and hierarchy tier"; } + if (index == 3) { return "Resolved semantic material token ID"; } + if (index == 4) { return "Resolved semantic fill token ID"; } + if (index == 5) { return "Visible semantic type token IDs"; } + if (index == 6) { return "Flex content spacing token ID"; } + if (index == 7) { return "Resolved radius and elevation token IDs"; } + if (index == 8) { return "Current showcase environment inputs"; } + if (index == 9) { return "Honest deterministic CPU material fallback"; } + return "Exact token provenance for the selected retained node"; +} + +fn linux_preview_composition_token_inspector( + artifact: *LinuxPreviewCompositionArtifact, + face: OpenTypeFace) i32 { + if (artifact.*.token_inspector_open == 0) { return 1; } + let surface: SurfaceBuildResult = + linux_preview_composition_surface_group(artifact, + linux_preview_composition_token_inspector_id(), + linux_preview_composition_main_id(), + linux_preview_token_inspector_bounds(artifact.*.layout), + "Token inspector", + "Exact provenance for the selected main Surface", + SurfaceTier.overlay, 100); + if (surface.error != SurfaceBuildError.none) { return 0; } + + let title_height: i64 = flex_spacing( + FlexSpacingToken.xxlarge, FlexDensity.standard); + let row_height: i64 = flex_spacing( + FlexSpacingToken.xlarge, FlexDensity.standard); + let gap: i64 = flex_spacing( + FlexSpacingToken.tiny, FlexDensity.standard); + let y: i64 = surface.artifact.content_bounds.y; + let index: i32 = 0; + while (index < 10) { + let height: i64 = row_height; + let type_token: SemanticTypeToken = SemanticTypeToken.caption; + let color_token: SemanticColorToken = + SemanticColorToken.text_secondary; + if (index == 0) { + height = title_height; + type_token = SemanticTypeToken.title; + color_token = SemanticColorToken.text_primary; + } else if (index == 1 || index == 2) { + type_token = SemanticTypeToken.label; + color_token = SemanticColorToken.text_primary; + } else if (index == 9) { + color_token = SemanticColorToken.status_experimental; + } + if (linux_preview_composition_add_static_text(artifact, face, + linux_preview_composition_token_inspector_line_id(index), + linux_preview_composition_token_inspector_id(), + rect(surface.artifact.content_bounds.x, y, + surface.artifact.content_bounds.width, height), + linux_preview_composition_token_inspector_line(index), + linux_preview_composition_token_inspector_description(index), + "", type_token, color_token, + TextAlignment.start) == 0) { return 0; } + artifact.*.token_inspector_text_instances = + artifact.*.token_inspector_text_instances + 1; + y = y + height + gap; + index = index + 1; + } + artifact.*.token_inspector_composed = 1; + return 1; +} + +fn linux_preview_composition_typography( + artifact: *LinuxPreviewCompositionArtifact, + light_face: *OpenTypeFace, + regular_face: *OpenTypeFace, + bold_face: *OpenTypeFace) i32 { + if (artifact.*.selected_navigation != 3) { return 1; } + if (artifact.*.typography_available == 0) { + artifact.*.error = LinuxPreviewCompositionError.typography_error; + artifact.*.diagnostics.typography_error = + TypographySpecimenError.invalid_spec; + return 0; + } + let spec: TypographySpecimenSpec = typography_specimen_spec( + linux_preview_composition_typography_id(), + linux_preview_typography_bounds(artifact.*.layout)); + let specimen: TypographySpecimenArtifact = typography_specimen_build( + spec, light_face, regular_face, bold_face); + if (typography_specimen_valid(&specimen) == 0) { + artifact.*.error = LinuxPreviewCompositionError.typography_error; + artifact.*.diagnostics.typography_error = specimen.error; + typography_specimen_free(&specimen); + return 0; + } + let hosted: TypographySpecimenHostResult = + typography_specimen_contribute_hosted(&specimen, + typography_specimen_host_spec( + linux_preview_composition_main_id()), + &artifact.*.display, &artifact.*.semantics); + if (hosted.error != TypographySpecimenHostError.none) { + artifact.*.error = LinuxPreviewCompositionError.typography_host_error; + artifact.*.diagnostics.typography_host_error = hosted.error; + artifact.*.diagnostics.display_error = hosted.display_error; + artifact.*.diagnostics.semantics_error = hosted.semantics_error; + typography_specimen_free(&specimen); + return 0; + } + artifact.*.typography_composed = 1; + artifact.*.typography_rows_composed = specimen.rows.len; + artifact.*.typography_display_operations = + hosted.display_operations_added; + artifact.*.typography_display_resources = + hosted.display_resources_added; + artifact.*.typography_semantic_nodes = hosted.semantic_nodes_added; + artifact.*.component_evidence_hash = linux_preview_composition_mix( + artifact.*.component_evidence_hash, specimen.evidence_hash); + typography_specimen_free(&specimen); + return 1; +} + +fn linux_preview_composition_components_host_available( + layout: LinuxPreviewLayout, face: *OpenTypeFace, + state: LinuxPreviewAppState) i32 { + if (layout.error != LinuxPreviewLayoutError.none || + layout.route_content.width <= 0 || + layout.route_content.height <= 0 || + text_font_valid(face.*) == 0 || + scroll_state_valid(state.components_scroll) != ScrollError.none) { + return 0; + } + let content: Size = linux_preview_composition_component_content_size(); + let gallery_spec: ComponentStateGallerySpec = + component_state_gallery_spec( + linux_preview_composition_component_gallery_id(), + rect(0, 0, content.width, content.height)); + let gallery: ComponentStateGalleryArtifact = + component_state_gallery_build_with_state( + gallery_spec, face, state.gallery_runtime); + let available: i32 = 0; + if (component_state_gallery_valid(&gallery) != 0) { + let viewport_spec: ScrollViewportSpec = + linux_preview_composition_components_viewport_spec( + layout, state.interaction); + let geometry: ScrollViewportGeometry = scroll_viewport_geometry( + viewport_spec, state.components_scroll); + available = (scroll_viewport_spec_valid(viewport_spec) != 0 && + geometry.valid != 0 && + scroll_viewport_content_valid(&gallery.display, + &gallery.semantics, &gallery.hits) != 0 && + scroll_viewport_capacity_error(viewport_spec, geometry, + &gallery.display, &gallery.semantics, + &gallery.hits) == ScrollViewportCapacityError.none) as i32; + } + component_state_gallery_free(&gallery); + return available; +} + +fn linux_preview_composition_components( + artifact: *LinuxPreviewCompositionArtifact, + face: *OpenTypeFace) i32 { + if (artifact.*.selected_navigation != 1) { return 1; } + if (artifact.*.components_available == 0) { + artifact.*.error = LinuxPreviewCompositionError.gallery_error; + artifact.*.diagnostics.gallery_error = + ComponentStateGalleryError.invalid_spec; + return 0; + } + let content: Size = linux_preview_composition_component_content_size(); + let gallery_spec: ComponentStateGallerySpec = + component_state_gallery_spec( + linux_preview_composition_component_gallery_id(), + rect(0, 0, content.width, content.height)); + let gallery: ComponentStateGalleryArtifact = + component_state_gallery_build_with_state(gallery_spec, face, + artifact.*.app_state.gallery_runtime); + if (component_state_gallery_valid(&gallery) == 0) { + artifact.*.error = LinuxPreviewCompositionError.gallery_error; + artifact.*.diagnostics.gallery_error = gallery.error; + artifact.*.diagnostics.display_error = gallery.display_error; + artifact.*.diagnostics.hit_error = gallery.hit_error; + artifact.*.diagnostics.semantics_error = gallery.semantics_error; + component_state_gallery_free(&gallery); + return 0; + } + + let viewport_spec: ScrollViewportSpec = + linux_preview_composition_components_viewport_spec( + artifact.*.layout, artifact.*.interaction); + let hosted: ScrollViewportBuildResult = scroll_viewport_contribute( + viewport_spec, artifact.*.app_state.components_scroll, + &gallery.display, &gallery.semantics, &gallery.hits, + &artifact.*.display, &artifact.*.semantics, &artifact.*.hits); + if (hosted.error != ScrollViewportBuildError.none) { + artifact.*.error = LinuxPreviewCompositionError.scroll_viewport_error; + artifact.*.diagnostics.scroll_viewport_error = hosted.error; + artifact.*.diagnostics.scroll_viewport_capacity_error = + hosted.capacity_error; + artifact.*.diagnostics.scroll_viewport_resource_error = + hosted.resource_error; + artifact.*.diagnostics.display_error = hosted.display_error; + artifact.*.diagnostics.hit_error = hosted.hit_error; + artifact.*.diagnostics.semantics_error = hosted.semantics_error; + component_state_gallery_free(&gallery); + return 0; + } + + artifact.*.component_gallery_composed = 1; + artifact.*.component_gallery_display_operations = gallery.display.ops.len; + artifact.*.component_gallery_display_resources = + gallery.display.resources.resources.len; + artifact.*.component_gallery_hit_nodes = gallery.hits.nodes.len; + artifact.*.component_gallery_semantic_nodes = gallery.semantics.nodes.len; + artifact.*.component_gallery_evidence_hash = gallery.evidence_hash; + artifact.*.component_viewport_composed = 1; + artifact.*.component_viewport_display_operations = + hosted.artifact.display_operations_added; + artifact.*.component_viewport_display_resources = + hosted.artifact.display_resources_added; + artifact.*.component_viewport_hit_nodes = + hosted.artifact.hit_node_end - hosted.artifact.hit_node_start; + artifact.*.component_viewport_semantic_nodes = + hosted.artifact.semantic_node_end - hosted.artifact.semantic_node_start; + artifact.*.component_viewport_state_hash = hosted.artifact.state_hash; + artifact.*.component_viewport_evidence_hash = hosted.artifact.evidence_hash; + artifact.*.component_viewport = hosted.artifact; + artifact.*.component_evidence_hash = linux_preview_composition_mix( + artifact.*.component_evidence_hash, gallery.evidence_hash); + artifact.*.component_evidence_hash = linux_preview_composition_mix( + artifact.*.component_evidence_hash, hosted.artifact.evidence_hash); + component_state_gallery_free(&gallery); + return 1; +} + +fn linux_preview_composition_main( + artifact: *LinuxPreviewCompositionArtifact, + light_face: *OpenTypeFace, + regular_face: *OpenTypeFace, + bold_face: *OpenTypeFace) i32 { + let face: OpenTypeFace = regular_face.*; + let surface: SurfaceBuildResult = + linux_preview_composition_surface_group(artifact, + linux_preview_composition_main_id(), + linux_preview_composition_root_id(), + artifact.*.layout.main, + linux_preview_main_heading(artifact.*.selected_navigation), + linux_preview_main_description(artifact.*.selected_navigation), + SurfaceTier.raised, 30); + if (surface.error != SurfaceBuildError.none) { return 0; } + let inspect_bounds: Rect = linux_preview_inspect_bounds(artifact.*.layout); + let heading_width: i64 = inspect_bounds.x - + artifact.*.layout.section_gap - artifact.*.layout.page_heading.x; + let heading_bounds: Rect = rect(artifact.*.layout.page_heading.x, + artifact.*.layout.page_heading.y, heading_width, + artifact.*.layout.page_heading.height); + let title_height: i64 = semantic_type_size(SemanticTypeToken.title) + + flex_spacing(FlexSpacingToken.tiny, FlexDensity.standard); + let title_bounds: Rect = rect(heading_bounds.x, heading_bounds.y, + heading_bounds.width, title_height); + let description_bounds: Rect = rect(heading_bounds.x, + heading_bounds.y + title_height, heading_bounds.width, + heading_bounds.height - title_height); + if (linux_preview_composition_add_static_text(artifact, face, + linux_preview_composition_heading_id(), + linux_preview_composition_main_id(), title_bounds, + linux_preview_main_heading(artifact.*.selected_navigation), + linux_preview_main_description(artifact.*.selected_navigation), "", + SemanticTypeToken.title, SemanticColorToken.text_primary, + TextAlignment.start) == 0) { return 0; } + if (linux_preview_composition_add_static_text(artifact, face, + linux_preview_composition_heading_description_id(), + linux_preview_composition_main_id(), description_bounds, + linux_preview_main_description(artifact.*.selected_navigation), + "Current route scope", "", + SemanticTypeToken.body, SemanticColorToken.text_secondary, + TextAlignment.start) == 0) { return 0; } + if (artifact.*.selected_navigation == 1) { + return linux_preview_composition_components(artifact, regular_face); + } + if (artifact.*.selected_navigation == 3) { + if (linux_preview_composition_typography(artifact, + light_face, regular_face, bold_face) == 0) { return 0; } + return 1; + } + if (artifact.*.selected_navigation != 0) { return 0; } + if (linux_preview_composition_inspect(artifact, face) == 0) { return 0; } + if (linux_preview_composition_segments(artifact, face) == 0) { return 0; } + if (linux_preview_composition_chart(artifact, face) == 0) { return 0; } + if (linux_preview_composition_compact_summary(artifact, face) == 0) { + return 0; + } + return linux_preview_composition_token_inspector(artifact, face); +} + +fn linux_preview_composition_can_show_unsupported_size( + width: i32, height: i32) i32 { + // The X11 shell clamps interactive resize to this transport minimum. A + // smaller or impossible surface cannot fit two truthful text lines, so it + // remains a hard error instead of publishing clipped accessibility truth. + return (width >= 320 && height >= 240 && + width <= 4096 && height <= 2160) as i32; +} + +fn linux_preview_composition_unsupported_size( + artifact: *LinuxPreviewCompositionArtifact, + face: OpenTypeFace, width: i32, height: i32) i32 { + artifact.*.layout.viewport = rect(0, 0, + (width as i64) * unit_scale(), (height as i64) * unit_scale()); + if (linux_preview_composition_backdrop(artifact) == 0) { return 0; } + + let root: SemanticsSpec = semantics_spec( + linux_preview_composition_root_id(), semantic_root_key(), + SemanticRole.window, "Zagkit Studio Linux Preview"); + root.description = + "The window is below the supported desktop composition size"; + root.has_bounds = 1; + root.bounds = artifact.*.layout.viewport; + if (linux_preview_composition_add_semantic(artifact, root) == 0) { + return 0; + } + if (linux_preview_composition_add_hit_parent(artifact, + linux_preview_composition_root_id(), hit_root_key(), + artifact.*.layout.viewport) == 0) { return 0; } + + let outer: i64 = flex_spacing( + FlexSpacingToken.large, FlexDensity.standard); + let panel: Rect = rect(outer, outer, + artifact.*.layout.viewport.width - outer * 2, + artifact.*.layout.viewport.height - outer * 2); + let id: NodeKey = linux_preview_composition_unsupported_id(); + let surface: SurfaceBuildResult = + linux_preview_composition_surface_group(artifact, + linux_preview_composition_unsupported_surface_id(), + linux_preview_composition_root_id(), panel, + "Unsupported viewport surface", + "Resize guidance for the desktop showcase", + SurfaceTier.raised, 20); + if (surface.error != SurfaceBuildError.none) { return 0; } + let message: SemanticsSpec = semantics_spec(id, + linux_preview_composition_unsupported_surface_id(), + SemanticRole.dialog, + "Window size unsupported"); + message.description = + "Resize the window to at least 480 by 360 logical pixels"; + message.value = "Minimum 480 by 360"; + message.live_region = LiveRegion.polite; + message.has_bounds = 1; + message.bounds = panel; + if (linux_preview_composition_add_semantic(artifact, message) == 0) { + return 0; + } + + let padding: i64 = flex_spacing( + FlexSpacingToken.xlarge, FlexDensity.standard); + let title_height: i64 = semantic_type_size(SemanticTypeToken.title) + + flex_spacing(FlexSpacingToken.xlarge, FlexDensity.standard); + let title: Rect = rect(panel.x + padding, panel.y + padding, + panel.width - padding * 2, title_height); + let body: Rect = rect(title.x, + title.y + title.height + + flex_spacing(FlexSpacingToken.small, FlexDensity.standard), + title.width, + panel.y + panel.height - padding - + (title.y + title.height + + flex_spacing(FlexSpacingToken.small, + FlexDensity.standard))); + if (linux_preview_composition_add_static_text(artifact, face, + linux_preview_composition_unsupported_title_id(), id, title, + "Resize required", "The desktop showcase needs more space", "", + SemanticTypeToken.title, SemanticColorToken.text_primary, + TextAlignment.start) == 0) { return 0; } + if (linux_preview_composition_add_static_text(artifact, face, + linux_preview_composition_unsupported_body_id(), id, body, + "Minimum 480 x 360 logical pixels", + "No chart or controls are published while the window is too small", + "480 x 360", SemanticTypeToken.body, + SemanticColorToken.text_secondary, + TextAlignment.start) == 0) { return 0; } + + artifact.*.unsupported_size_composed = 1; + let sealed: DisplayError = display_list_seal(&artifact.*.display); + if (sealed != DisplayError.none) { + artifact.*.error = LinuxPreviewCompositionError.seal_error; + artifact.*.diagnostics.display_error = sealed; + return 0; + } + return 1; +} + +fn linux_preview_composition_build_internal(width: i32, height: i32, + light_face: *OpenTypeFace, + regular_face: *OpenTypeFace, + bold_face: *OpenTypeFace, + typography_fonts_available: i32, state: LinuxPreviewAppState) + LinuxPreviewCompositionArtifact { + let face: OpenTypeFace = regular_face.*; + let layout: LinuxPreviewLayout = linux_preview_layout(width, height); + let normalized_state: LinuxPreviewAppState = state; + let state_valid: i32 = linux_preview_app_state_valid(state); + let components_available: i32 = 0; + if (state_valid != 0 && + layout.error == LinuxPreviewLayoutError.none) { + let reconciled: ScrollMutation = + linux_preview_app_state_reconcile_components( + &normalized_state, layout); + if (reconciled.error == ScrollError.none) { + components_available = + linux_preview_composition_components_host_available( + layout, regular_face, normalized_state); + } + } + let artifact: LinuxPreviewCompositionArtifact = + linux_preview_composition_empty( + layout, normalized_state, components_available, + typography_fonts_available); + if (state_valid == 0 || + (layout.error == LinuxPreviewLayoutError.none && + scroll_state_valid( + normalized_state.components_scroll) != ScrollError.none)) { + artifact.error = LinuxPreviewCompositionError.invalid_layout; + return artifact; + } + if (layout.error != LinuxPreviewLayoutError.none) { + if (layout.error == LinuxPreviewLayoutError.invalid_viewport && + linux_preview_composition_can_show_unsupported_size( + width, height) != 0) { + if (linux_preview_composition_unsupported_size( + &artifact, face, width, height) != 0) { + artifact.evidence_hash = + linux_preview_composition_hash(artifact); + } + return artifact; + } + artifact.error = LinuxPreviewCompositionError.invalid_layout; + return artifact; + } + if (linux_preview_composition_backdrop(&artifact) == 0) { return artifact; } + let root: SemanticsSpec = semantics_spec( + linux_preview_composition_root_id(), semantic_root_key(), + SemanticRole.window, "Zagkit Studio Linux Preview"); + root.description = + "Experimental first-party Zagkit component composition"; + root.has_bounds = 1; + root.bounds = layout.viewport; + if (linux_preview_composition_add_semantic(&artifact, root) == 0) { + return artifact; + } + if (linux_preview_composition_add_hit_parent(&artifact, + linux_preview_composition_root_id(), hit_root_key(), + layout.viewport) == 0) { return artifact; } + if (linux_preview_composition_header(&artifact, face) == 0) { + return artifact; + } + if (linux_preview_composition_navigation(&artifact, face) == 0) { + return artifact; + } + if (linux_preview_composition_main(&artifact, + light_face, regular_face, bold_face) == 0) { + return artifact; + } + if (linux_preview_composition_status(&artifact, face) == 0) { + return artifact; + } + let sealed: DisplayError = display_list_seal(&artifact.display); + if (sealed != DisplayError.none) { + artifact.error = LinuxPreviewCompositionError.seal_error; + artifact.diagnostics.display_error = sealed; + return artifact; + } + artifact.evidence_hash = linux_preview_composition_hash(artifact); + return artifact; +} + +fn linux_preview_composition_build_with_fonts_state( + width: i32, height: i32, + light_face: *OpenTypeFace, + regular_face: *OpenTypeFace, + bold_face: *OpenTypeFace, + selected_navigation: i32, segmented_model: SegmentedControlModel, + token_inspector_open: i32) LinuxPreviewCompositionArtifact { + let state: LinuxPreviewAppState = linux_preview_app_state(); + if (linux_preview_navigation_index_valid(selected_navigation) != 0) { + state.selected_navigation = selected_navigation; + } + if (segmented_control_model_valid(segmented_model) != 0 && + segmented_model.segment_count == 4 && + segmented_control_index_enabled(segmented_model, 3) == 0) { + state.overview_segmented = segmented_model; + } + state.token_inspector_open = (token_inspector_open != 0) as i32; + return linux_preview_composition_build_internal(width, height, + light_face, regular_face, bold_face, 1, state); +} + +fn linux_preview_composition_build_with_fonts_interaction_state( + width: i32, height: i32, + light_face: *OpenTypeFace, + regular_face: *OpenTypeFace, + bold_face: *OpenTypeFace, + selected_navigation: i32, segmented_model: SegmentedControlModel, + token_inspector_open: i32, interaction: LinuxPreviewInteractionState) + LinuxPreviewCompositionArtifact { + let state: LinuxPreviewAppState = linux_preview_app_state(); + if (linux_preview_navigation_index_valid(selected_navigation) != 0) { + state.selected_navigation = selected_navigation; + } + if (segmented_control_model_valid(segmented_model) != 0 && + segmented_model.segment_count == 4 && + segmented_control_index_enabled(segmented_model, 3) == 0) { + state.overview_segmented = segmented_model; + } + state.token_inspector_open = (token_inspector_open != 0) as i32; + state.interaction = interaction; + return linux_preview_composition_build_internal(width, height, + light_face, regular_face, bold_face, 1, state); +} + +fn linux_preview_composition_build_with_fonts_app_state( + width: i32, height: i32, + light_face: *OpenTypeFace, + regular_face: *OpenTypeFace, + bold_face: *OpenTypeFace, + state: LinuxPreviewAppState) LinuxPreviewCompositionArtifact { + return linux_preview_composition_build_internal(width, height, + light_face, regular_face, bold_face, 1, state); +} + +fn linux_preview_composition_build_with_state(width: i32, height: i32, + face: OpenTypeFace, selected_navigation: i32, + segmented_model: SegmentedControlModel, token_inspector_open: i32) + LinuxPreviewCompositionArtifact { + let state: LinuxPreviewAppState = linux_preview_app_state(); + if (linux_preview_navigation_index_valid(selected_navigation) != 0) { + state.selected_navigation = selected_navigation; + } + if (segmented_control_model_valid(segmented_model) != 0 && + segmented_model.segment_count == 4 && + segmented_control_index_enabled(segmented_model, 3) == 0) { + state.overview_segmented = segmented_model; + } + state.token_inspector_open = (token_inspector_open != 0) as i32; + return linux_preview_composition_build_internal(width, height, + &face, &face, &face, 0, state); +} + +fn linux_preview_composition_build_with_interaction_state( + width: i32, height: i32, face: OpenTypeFace, + selected_navigation: i32, segmented_model: SegmentedControlModel, + token_inspector_open: i32, interaction: LinuxPreviewInteractionState) + LinuxPreviewCompositionArtifact { + let state: LinuxPreviewAppState = linux_preview_app_state(); + if (linux_preview_navigation_index_valid(selected_navigation) != 0) { + state.selected_navigation = selected_navigation; + } + if (segmented_control_model_valid(segmented_model) != 0 && + segmented_model.segment_count == 4 && + segmented_control_index_enabled(segmented_model, 3) == 0) { + state.overview_segmented = segmented_model; + } + state.token_inspector_open = (token_inspector_open != 0) as i32; + state.interaction = interaction; + return linux_preview_composition_build_internal(width, height, + &face, &face, &face, 0, state); +} + +fn linux_preview_composition_build_with_app_state( + width: i32, height: i32, face: OpenTypeFace, + state: LinuxPreviewAppState) LinuxPreviewCompositionArtifact { + return linux_preview_composition_build_internal(width, height, + &face, &face, &face, 0, state); +} + +fn linux_preview_composition_build_with_model(width: i32, height: i32, + face: OpenTypeFace, selected_navigation: i32, + segmented_model: SegmentedControlModel) + LinuxPreviewCompositionArtifact { + return linux_preview_composition_build_with_state(width, height, face, + selected_navigation, segmented_model, 0); +} + +fn linux_preview_composition_build_with_segment(width: i32, height: i32, + face: OpenTypeFace, selected_navigation: i32, selected_segment: i32) + LinuxPreviewCompositionArtifact { + return linux_preview_composition_build_with_model(width, height, face, + selected_navigation, + linux_preview_composition_segmented_model_for(selected_segment)); +} + +fn linux_preview_composition_build(width: i32, height: i32, + face: OpenTypeFace, selected_navigation: i32) + LinuxPreviewCompositionArtifact { + return linux_preview_composition_build_with_segment( + width, height, face, selected_navigation, 0); +} + +fn linux_preview_composition_valid( + artifact: LinuxPreviewCompositionArtifact) i32 { + if (artifact.error != LinuxPreviewCompositionError.none || + linux_preview_app_state_valid(artifact.app_state) == 0 || + linux_preview_interaction_state_valid(artifact.interaction) == 0 || + artifact.selected_navigation != + artifact.app_state.selected_navigation || + artifact.token_inspector_open != + artifact.app_state.token_inspector_open || + component_state_gallery_segmented_model_equal( + artifact.segmented_model, + artifact.app_state.overview_segmented) == 0 || + node_key_equal(artifact.interaction.hovered, + artifact.app_state.interaction.hovered) == 0 || + node_key_equal(artifact.interaction.pressed, + artifact.app_state.interaction.pressed) == 0 || + node_key_equal(artifact.interaction.focused, + artifact.app_state.interaction.focused) == 0 || + artifact.interaction.focus_visible != + artifact.app_state.interaction.focus_visible || + artifact.evidence_hash == 0 || + artifact.evidence_hash != linux_preview_composition_hash(artifact) || + display_list_verify(&artifact.display) != DisplayError.none || + artifact.semantics.last_error != SemanticsError.none || + artifact.hits.last_error != HitTreeError.none || + semantics_find_index(artifact.semantics, + linux_preview_composition_root_id()) < 0 || + hit_tree_find_index(artifact.hits, + linux_preview_composition_root_id()) < 0) { return 0; } + if (artifact.layout.error != LinuxPreviewLayoutError.none) { + return (artifact.layout.error == + LinuxPreviewLayoutError.invalid_viewport && + artifact.unsupported_size_composed == 1 && + artifact.navigation_items_composed == 0 && + artifact.components_available == 0 && + artifact.inspect_composed == 0 && + artifact.token_inspector_open == 0 && + artifact.token_inspector_composed == 0 && + artifact.token_inspector_text_instances == 0 && + artifact.segmented_control_composed == 0 && + artifact.segmented_items_composed == 0 && + artifact.surfaces_composed == 1 && + artifact.chart_composed == 0 && + artifact.chart_text_slots_composed == 0 && + artifact.typography_available == 0 && + artifact.typography_composed == 0 && + artifact.typography_rows_composed == 0 && + artifact.typography_display_operations == 0 && + artifact.typography_display_resources == 0 && + artifact.typography_semantic_nodes == 0 && + artifact.component_gallery_composed == 0 && + artifact.component_gallery_display_operations == 0 && + artifact.component_gallery_display_resources == 0 && + artifact.component_gallery_hit_nodes == 0 && + artifact.component_gallery_semantic_nodes == 0 && + artifact.component_gallery_evidence_hash == 0 && + artifact.component_viewport_composed == 0 && + artifact.component_viewport_display_operations == 0 && + artifact.component_viewport_display_resources == 0 && + artifact.component_viewport_hit_nodes == 0 && + artifact.component_viewport_semantic_nodes == 0 && + artifact.component_viewport_state_hash == 0 && + artifact.component_viewport_evidence_hash == 0 && + artifact.compact_summary_composed == 0 && + artifact.status_items_composed == 0 && + artifact.text_instances_composed == 2 && + semantics_find_index(artifact.semantics, + linux_preview_composition_unsupported_id()) >= 0) as i32; + } + if (artifact.unsupported_size_composed != 0 || + artifact.components_available != 1 || + segmented_control_model_valid(artifact.segmented_model) == 0 || + artifact.segmented_model.segment_count != 4 || + segmented_control_index_enabled(artifact.segmented_model, 3) != 0 || + artifact.navigation_items_composed != + linux_preview_navigation_count()) { return 0; } + if (node_key_equal(artifact.interaction.focused, + hit_root_key()) == 0) { + let semantic_index: i32 = semantics_find_index( + artifact.semantics, artifact.interaction.focused); + let hit_index: i32 = hit_tree_find_index( + artifact.hits, artifact.interaction.focused); + if (semantic_index < 0 || hit_index < 0 || + artifact.semantics.nodes.data[semantic_index].focused == 0 || + artifact.semantics.nodes.data[semantic_index].disabled != 0 || + artifact.semantics.nodes.data[semantic_index].focus_order <= 0 || + artifact.hits.nodes.data[hit_index].enabled == 0 || + artifact.hits.nodes.data[hit_index].focusable == 0) { + return 0; + } + } + if (artifact.selected_navigation != 0 && + (artifact.inspect_composed != 0 || + artifact.token_inspector_open != 0)) { return 0; } + if (artifact.selected_navigation == 0 && + artifact.inspect_composed != 1) { return 0; } + if (artifact.token_inspector_open != 0) { + if (linux_preview_token_inspector_available(artifact.layout) == 0 || + artifact.token_inspector_composed != 1 || + artifact.token_inspector_text_instances != 10 || + semantics_find_index(artifact.semantics, + linux_preview_composition_token_inspector_id()) < 0) { + return 0; + } + } else if (artifact.token_inspector_composed != 0 || + artifact.token_inspector_text_instances != 0 || + semantics_find_index(artifact.semantics, + linux_preview_composition_token_inspector_id()) >= 0) { + return 0; + } + let expected_surfaces: i32 = 3; + if (artifact.layout.status_visible != 0) { + expected_surfaces = expected_surfaces + 1; + } + if (artifact.selected_navigation == 0 && + artifact.layout.chart_visible == 0) { + expected_surfaces = expected_surfaces + 1; + } + expected_surfaces = expected_surfaces + artifact.token_inspector_open; + if (artifact.surfaces_composed != expected_surfaces) { return 0; } + if (artifact.selected_navigation != 1 && + (artifact.component_gallery_composed != 0 || + artifact.component_gallery_display_operations != 0 || + artifact.component_gallery_display_resources != 0 || + artifact.component_gallery_hit_nodes != 0 || + artifact.component_gallery_semantic_nodes != 0 || + artifact.component_gallery_evidence_hash != 0 || + artifact.component_viewport_composed != 0 || + artifact.component_viewport_display_operations != 0 || + artifact.component_viewport_display_resources != 0 || + artifact.component_viewport_hit_nodes != 0 || + artifact.component_viewport_semantic_nodes != 0 || + artifact.component_viewport_state_hash != 0 || + artifact.component_viewport_evidence_hash != 0)) { return 0; } + if (artifact.selected_navigation == 1) { + let expected_viewport_ops: i32 = + artifact.component_gallery_display_operations + 4 + + artifact.component_viewport.geometry.horizontal_track_visible * 2 + + artifact.component_viewport.geometry.vertical_track_visible * 2 + + (artifact.component_viewport.focused != 0 && + artifact.component_viewport.focus_ring_visible != 0) as i32; + let expected_viewport_semantics: i32 = + artifact.component_gallery_semantic_nodes + 1 + + artifact.component_viewport.geometry.horizontal_track_visible + + artifact.component_viewport.geometry.vertical_track_visible; + if (artifact.component_gallery_composed != 1 || + artifact.component_gallery_display_operations != + component_state_gallery_expected_display_operations() || + artifact.component_gallery_display_resources != + component_state_gallery_expected_display_resources() || + artifact.component_gallery_hit_nodes != + component_state_gallery_expected_hit_nodes() || + artifact.component_gallery_semantic_nodes != + component_state_gallery_expected_semantic_nodes() || + artifact.component_gallery_evidence_hash == 0 || + artifact.component_viewport_composed != 1 || + node_key_equal(artifact.component_viewport.id, + linux_preview_composition_components_viewport_id()) == 0 || + artifact.component_viewport.geometry.valid == 0 || + artifact.component_viewport.geometry.viewport_bounds.x != + artifact.layout.route_content.x || + artifact.component_viewport.geometry.viewport_bounds.y != + artifact.layout.route_content.y || + artifact.component_viewport.geometry.viewport_bounds.width != + artifact.layout.route_content.width || + artifact.component_viewport.geometry.viewport_bounds.height != + artifact.layout.route_content.height || + artifact.component_viewport_display_operations != + expected_viewport_ops || + artifact.component_viewport_display_resources != + artifact.component_gallery_display_resources || + artifact.component_viewport_hit_nodes != + artifact.component_gallery_hit_nodes + 1 || + artifact.component_viewport_semantic_nodes != + expected_viewport_semantics || + artifact.component_viewport_state_hash != + scroll_state_hash(artifact.app_state.components_scroll) || + artifact.component_viewport_state_hash != + artifact.component_viewport.state_hash || + artifact.component_viewport_evidence_hash == 0 || + artifact.component_viewport_evidence_hash != + artifact.component_viewport.evidence_hash || + artifact.typography_composed != 0 || + artifact.typography_rows_composed != 0 || + artifact.typography_display_operations != 0 || + artifact.typography_display_resources != 0 || + artifact.typography_semantic_nodes != 0 || + artifact.chart_composed != 0 || + artifact.chart_text_slots_composed != 0 || + artifact.segmented_control_composed != 0 || + artifact.segmented_items_composed != 0 || + artifact.compact_summary_composed != 0 || + semantics_find_index(artifact.semantics, + linux_preview_composition_components_viewport_id()) < 0 || + semantics_find_index(artifact.semantics, + linux_preview_composition_component_gallery_id()) < 0 || + semantics_find_index(artifact.semantics, + linux_preview_composition_chart_id()) >= 0 || + semantics_find_index(artifact.semantics, + linux_preview_composition_segmented_id()) >= 0 || + semantics_find_index(artifact.semantics, + linux_preview_composition_typography_id()) >= 0 || + semantics_find_index(artifact.semantics, + linux_preview_composition_inspect_id()) >= 0) { return 0; } + } else if (artifact.selected_navigation == 3) { + if (artifact.typography_available == 0 || + artifact.layout.mode != LinuxPreviewLayoutMode.expanded || + artifact.typography_composed != 1 || + artifact.typography_rows_composed != + typography_specimen_row_count() || + artifact.typography_display_operations != + typography_specimen_row_count() * 10 || + artifact.typography_display_resources != + typography_specimen_row_count() * 2 || + artifact.typography_semantic_nodes != + 1 + typography_specimen_row_count() * 3 || + artifact.chart_composed != 0 || + artifact.chart_text_slots_composed != 0 || + artifact.segmented_control_composed != 0 || + artifact.segmented_items_composed != 0 || + artifact.compact_summary_composed != 0 || + artifact.component_gallery_composed != 0 || + artifact.component_viewport_composed != 0 || + semantics_find_index(artifact.semantics, + linux_preview_composition_typography_id()) < 0 || + semantics_find_index(artifact.semantics, + linux_preview_composition_components_viewport_id()) >= 0 || + semantics_find_index(artifact.semantics, + linux_preview_composition_inspect_id()) >= 0) { return 0; } + } else { + if (artifact.selected_navigation != 0 || + artifact.typography_composed != 0 || + artifact.typography_rows_composed != 0 || + artifact.typography_display_operations != 0 || + artifact.typography_display_resources != 0 || + artifact.typography_semantic_nodes != 0 || + artifact.component_gallery_composed != 0 || + artifact.component_viewport_composed != 0 || + semantics_find_index(artifact.semantics, + linux_preview_composition_components_viewport_id()) >= 0 || + semantics_find_index(artifact.semantics, + linux_preview_composition_component_gallery_id()) >= 0 || + semantics_find_index(artifact.semantics, + linux_preview_composition_typography_id()) >= 0) { return 0; } + if (artifact.layout.chart_visible != 0) { + if (artifact.chart_composed != 1 || + artifact.chart_text_slots_composed != 24 || + artifact.segmented_control_composed != 1 || + artifact.segmented_items_composed != 4 || + artifact.compact_summary_composed != 0) { return 0; } + } else if (artifact.chart_composed != 0 || + artifact.chart_text_slots_composed != 0 || + artifact.segmented_control_composed != 0 || + artifact.segmented_items_composed != 0 || + artifact.compact_summary_composed != 1) { return 0; } + } + if (artifact.layout.status_visible != 0) { + if (artifact.status_items_composed != 5) { return 0; } + } else if (artifact.status_items_composed != 0) { return 0; } + let expected_text_instances: i32 = 10; + if (artifact.selected_navigation == 0) { + expected_text_instances = expected_text_instances + 1; + if (artifact.layout.chart_visible != 0) { + expected_text_instances = expected_text_instances + 28; + } else { + expected_text_instances = expected_text_instances + 2; + } + } + if (artifact.layout.status_visible != 0) { + expected_text_instances = expected_text_instances + 12; + } + expected_text_instances = expected_text_instances + + artifact.token_inspector_text_instances; + if (artifact.text_instances_composed != expected_text_instances) { + return 0; + } + return 1; +} + +fn linux_preview_composition_free( + artifact: *LinuxPreviewCompositionArtifact) void { + hit_tree_free(&artifact.*.hits); + semantics_tree_free(&artifact.*.semantics); + display_list_free(&artifact.*.display); + artifact.*.app_state = linux_preview_app_state(); + artifact.*.interaction = linux_preview_interaction_state(); + artifact.*.selected_navigation = linux_preview_initial_navigation(); + artifact.*.components_available = 0; + artifact.*.segmented_model = linux_preview_composition_segmented_model(); + artifact.*.navigation_items_composed = 0; + artifact.*.inspect_composed = 0; + artifact.*.token_inspector_open = 0; + artifact.*.token_inspector_composed = 0; + artifact.*.token_inspector_text_instances = 0; + artifact.*.segmented_control_composed = 0; + artifact.*.segmented_items_composed = 0; + artifact.*.surfaces_composed = 0; + artifact.*.chart_composed = 0; + artifact.*.chart_text_slots_composed = 0; + artifact.*.typography_available = 0; + artifact.*.typography_composed = 0; + artifact.*.typography_rows_composed = 0; + artifact.*.typography_display_operations = 0; + artifact.*.typography_display_resources = 0; + artifact.*.typography_semantic_nodes = 0; + artifact.*.component_gallery_composed = 0; + artifact.*.component_gallery_display_operations = 0; + artifact.*.component_gallery_display_resources = 0; + artifact.*.component_gallery_hit_nodes = 0; + artifact.*.component_gallery_semantic_nodes = 0; + artifact.*.component_gallery_evidence_hash = 0; + artifact.*.component_viewport_composed = 0; + artifact.*.component_viewport_display_operations = 0; + artifact.*.component_viewport_display_resources = 0; + artifact.*.component_viewport_hit_nodes = 0; + artifact.*.component_viewport_semantic_nodes = 0; + artifact.*.component_viewport_state_hash = 0; + artifact.*.component_viewport_evidence_hash = 0; + let viewport_spec: ScrollViewportSpec = + linux_preview_composition_components_viewport_spec( + artifact.*.layout, artifact.*.interaction); + artifact.*.component_viewport = scroll_viewport_empty_artifact( + viewport_spec, artifact.*.app_state.components_scroll); + artifact.*.compact_summary_composed = 0; + artifact.*.unsupported_size_composed = 0; + artifact.*.status_items_composed = 0; + artifact.*.text_instances_composed = 0; + artifact.*.component_evidence_hash = 0; + artifact.*.evidence_hash = 0; +} diff --git a/src/showcase/linux_preview_layout.zag b/src/showcase/linux_preview_layout.zag index d4eec6e..2d2d8c6 100644 --- a/src/showcase/linux_preview_layout.zag +++ b/src/showcase/linux_preview_layout.zag @@ -27,6 +27,9 @@ struct LinuxPreviewLayout { chart: Rect, segmented_control: Rect, compact_summary: Rect, + route_content: Rect, + status_heading: Rect, + status_list: Rect, navigation_is_column: i32, status_visible: i32, chart_visible: i32, @@ -53,6 +56,9 @@ fn linux_preview_layout_empty(error: LinuxPreviewLayoutError) .chart = rect(0, 0, 0, 0), .segmented_control = rect(0, 0, 0, 0), .compact_summary = rect(0, 0, 0, 0), + .route_content = rect(0, 0, 0, 0), + .status_heading = rect(0, 0, 0, 0), + .status_list = rect(0, 0, 0, 0), .navigation_is_column = 0, .status_visible = 0, .chart_visible = 0, @@ -238,6 +244,30 @@ fn linux_preview_layout(width: i32, height: i32) LinuxPreviewLayout { result.chart_visible == 0) { result.error = LinuxPreviewLayoutError.chart_contract_violation; } + result.route_content = rect(result.main.x + result.panel_padding, + result.page_heading.y + result.page_heading.height + + result.section_gap, + inner_width, + result.main.y + result.main.height - result.panel_padding - + (result.page_heading.y + result.page_heading.height + + result.section_gap)); + if (result.status_visible != 0) { + let status_inner_w: i64 = result.status.width - + result.panel_padding * 2; + let status_heading_h: i64 = + flex_spacing(FlexSpacingToken.xxlarge, density) + + flex_spacing(FlexSpacingToken.large, density); + result.status_heading = rect(result.status.x + result.panel_padding, + result.status.y + result.panel_padding, + status_inner_w, status_heading_h); + result.status_list = rect(result.status_heading.x, + result.status_heading.y + result.status_heading.height + + result.section_gap, + status_inner_w, + result.status.y + result.status.height - result.panel_padding - + (result.status_heading.y + result.status_heading.height + + result.section_gap)); + } return result; } diff --git a/src/showcase/linux_preview_model.zag b/src/showcase/linux_preview_model.zag index 3a70a23..2230b0e 100644 --- a/src/showcase/linux_preview_model.zag +++ b/src/showcase/linux_preview_model.zag @@ -16,10 +16,10 @@ fn linux_preview_navigation_name(index: i32) []u8 { } fn linux_preview_navigation_enabled(index: i32) i32 { - // The overview is currently the only route fully composed into the native - // shell. Components becomes available when the real Button gallery is - // embedded; a highlight-only destination is deliberately unavailable. - return (index == 0) as i32; + // Overview, Components, and Typography are fully composed into the native + // shell. Remaining routes are highlight-only destinations, deliberately + // unavailable. + return (index == 0 || index == 1 || index == 3) as i32; } fn linux_preview_navigation_value(index: i32) []u8 { @@ -33,6 +33,11 @@ fn linux_preview_navigation_normalize(index: i32) i32 { return linux_preview_initial_navigation(); } +fn linux_preview_navigation_index_valid(index: i32) i32 { + return (index >= 0 && index < linux_preview_navigation_count() && + linux_preview_navigation_enabled(index) != 0) as i32; +} + fn linux_preview_main_heading(index: i32) []u8 { let selected: i32 = linux_preview_navigation_normalize(index); if (selected == 1) { return "Button states"; } diff --git a/src/showcase/linux_preview_scene.zag b/src/showcase/linux_preview_scene.zag index 8a8ad2e..2522285 100644 --- a/src/showcase/linux_preview_scene.zag +++ b/src/showcase/linux_preview_scene.zag @@ -144,17 +144,30 @@ fn preview_glass_panel(list: *DisplayList, id: i64, x: i64, y: i64, width: i64, height: i64, radius: i64) void { let elevation: ResolvedElevationToken = semantic_elevation( SemanticElevationToken.panel); - let spread: i64 = elevation.shadow_spread; - _ = preview_round_rect(list, id, x - spread, y + elevation.shadow_offset_y, - width + spread * 2, height + spread * 2, radius + spread, + // Ambient shadow: soft, larger offset. + let ambient_blur: i64 = elevation.ambient_shadow_blur; + _ = preview_round_rect(list, id, + x - ambient_blur, y + elevation.ambient_shadow_y, + width + ambient_blur * 2, height + ambient_blur * 2, + radius + ambient_blur, semantic_color_with_alpha(SemanticColorToken.shadow, - elevation.shadow_alpha)); - _ = preview_round_rect(list, id + 1, x, y, width, height, radius, + elevation.ambient_shadow_alpha)); + // Contact shadow: tight, small offset. + let contact_blur: i64 = elevation.contact_shadow_blur; + _ = preview_round_rect(list, id + 1, + x - contact_blur, y + elevation.contact_shadow_y, + width + contact_blur * 2, height + contact_blur * 2, + radius + contact_blur, + semantic_color_with_alpha(SemanticColorToken.shadow, + elevation.contact_shadow_alpha)); + // Fill rect. + _ = preview_round_rect(list, id + 2, x, y, width, height, radius, semantic_color_paint(SemanticColorToken.surface_base)); - _ = preview_round_rect(list, id + 2, x + unit_scale(), y + unit_scale(), + // Edge highlight: top edge hairline. + _ = preview_round_rect(list, id + 3, x + unit_scale(), y + unit_scale(), width - 2 * unit_scale(), 2 * unit_scale(), radius, semantic_color_with_alpha(SemanticColorToken.edge_highlight, - elevation.border_alpha)); + elevation.edge_highlight_alpha)); } fn linux_preview_scene_internal(width: i32, height: i32, diff --git a/src/showcase/typography_specimen.zag b/src/showcase/typography_specimen.zag new file mode 100644 index 0000000..569a484 --- /dev/null +++ b/src/showcase/typography_specimen.zag @@ -0,0 +1,1345 @@ +@import("../components/text.zag") +@import("../automation/talkback.zag") + +// Experimental, headless Typography conformance specimen. It composes the +// canonical Text component through Flex and emits one immutable DisplayList +// plus a parallel semantics tree. This is system evidence, not a substitute +// for the shaping, bidi, fallback, or continuous scaling work still upstream. + +enum TypographySpecimenSection { type_ramp, weight, contrast } +enum TypographySpecimenWeight { light, regular, bold } + +enum TypographySpecimenError { + none, + invalid_spec, + missing_font, + weight_not_visible, + layout_error, + layout_overflow, + semantics_error, + text_error, + contribution_error, + display_error, +} + +struct TypographySpecimenSpec { + id: NodeKey, + bounds: Rect, + name: []u8, + ramp_sample: []u8, + weight_sample: []u8, + contrast_sample: []u8, + density: FlexDensity, + direction: LayoutDirection, + text_scale: i64, +} + +struct TypographySpecimenRow { + index: i32, + section: TypographySpecimenSection, + row_id: NodeKey, + label_id: NodeKey, + sample_id: NodeKey, + bounds: Rect, + label_bounds: Rect, + sample_bounds: Rect, + requested_type: SemanticTypeToken, + resolved_type: SemanticTypeToken, + label_type: SemanticTypeToken, + weight: TypographySpecimenWeight, + color: SemanticColorToken, + label_alignment: TextAlignment, + sample_alignment: TextAlignment, + disabled: i32, + resolved_font_size: i64, + resolved_label_font_size: i64, + label_evidence_hash: i64, + sample_evidence_hash: i64, + sample_path_payload_hash: i64, +} + +struct TypographySpecimenArtifact { + display: DisplayList, + semantics: SemanticsTree, + rows: ArrayList[TypographySpecimenRow], + id: NodeKey, + bounds: Rect, + density: FlexDensity, + direction: LayoutDirection, + text_scale: i64, + scale_steps: i32, + outer_padding: i64, + row_gap: i64, + row_padding: i64, + column_gap: i64, + required_height: i64, + structural_minimum_width: i64, + rtl_layout_applied: i32, + nominal_ltr_text_only: i32, + bidi_shaping_available: i32, + font_fallback_available: i32, + continuous_text_scaling_available: i32, + code_family_routing_available: i32, + weight_metadata_verified: i32, + weight_shape_proof: i32, + light_probe_hash: i64, + regular_probe_hash: i64, + bold_probe_hash: i64, + display_hash: i64, + talkback_hash: i64, + evidence_hash: i64, + error: TypographySpecimenError, + missing_face_index: i32, + error_row: i32, + flex_error: FlexError, + text_error: TextError, + contribution_error: TextContributionError, + semantics_error: SemanticsError, + display_error: DisplayError, +} + +enum TypographySpecimenHostError { + none, + invalid_artifact, + invalid_destination, + invalid_semantic_parent, + missing_semantic_parent, + duplicate_semantic_id, + resource_collision, + resource_error, + display_error, + semantics_error, +} + +struct TypographySpecimenHostSpec { + semantic_parent: NodeKey, +} + +struct TypographySpecimenHostResult { + error: TypographySpecimenHostError, + resource_error: RenderResourceError, + display_error: DisplayError, + semantics_error: SemanticsError, + display_operations_added: i32, + display_resources_added: i32, + semantic_nodes_added: i32, +} + +struct TypographySpecimenDisplayStage { + display: DisplayList, + valid: i32, +} + +fn typography_specimen_row_count() i32 { return 13; } +fn typography_specimen_type_row_count() i32 { return 7; } +fn typography_specimen_weight_row_start() i32 { return 7; } +fn typography_specimen_contrast_row_start() i32 { return 10; } +fn typography_specimen_max_root_id() i64 { return 1000000000; } + +fn typography_specimen_spec(id: NodeKey, bounds: Rect) + TypographySpecimenSpec { + return TypographySpecimenSpec{ + .id = id, + .bounds = bounds, + .name = "Typography specimen", + .ramp_sample = "Zagkit builds fluent interfaces", + .weight_sample = "Weight has visible structure 0123", + .contrast_sample = "Readable hierarchy at every level", + .density = FlexDensity.standard, + .direction = LayoutDirection.ltr, + .text_scale = unit_scale(), + }; +} + +fn typography_specimen_empty(spec: TypographySpecimenSpec) + TypographySpecimenArtifact { + return TypographySpecimenArtifact{ + .display = display_list_make(), + .semantics = semantics_tree_make(), + .rows = make[TypographySpecimenRow](typography_specimen_row_count()), + .id = spec.id, + .bounds = spec.bounds, + .density = spec.density, + .direction = spec.direction, + .text_scale = spec.text_scale, + .scale_steps = 0, + .outer_padding = 0, + .row_gap = 0, + .row_padding = 0, + .column_gap = 0, + .required_height = 0, + .structural_minimum_width = 0, + .rtl_layout_applied = 0, + .nominal_ltr_text_only = 1, + .bidi_shaping_available = 0, + .font_fallback_available = 0, + .continuous_text_scaling_available = 0, + .code_family_routing_available = 0, + .weight_metadata_verified = 0, + .weight_shape_proof = 0, + .light_probe_hash = 0, + .regular_probe_hash = 0, + .bold_probe_hash = 0, + .display_hash = 0, + .talkback_hash = 0, + .evidence_hash = 0, + .error = TypographySpecimenError.none, + .missing_face_index = 0 - 1, + .error_row = 0 - 1, + .flex_error = FlexError.none, + .text_error = TextError.none, + .contribution_error = TextContributionError.none, + .semantics_error = SemanticsError.none, + .display_error = DisplayError.none, + }; +} + +fn typography_specimen_density_valid(density: FlexDensity) i32 { + let value: i64 = density as i64; + return (value >= FlexDensity.compact as i64 && + value <= FlexDensity.touch as i64) as i32; +} + +fn typography_specimen_direction_valid(direction: LayoutDirection) i32 { + let value: i64 = direction as i64; + return (value >= LayoutDirection.ltr as i64 && + value <= LayoutDirection.rtl as i64) as i32; +} + +fn typography_specimen_sample_valid(value: []u8) i32 { + return (value.len > 0 && value.len <= text_byte_limit() && + semantic_text_input_valid(value) != 0) as i32; +} + +fn typography_specimen_spec_valid(spec: TypographySpecimenSpec) i32 { + return (canvas_key_valid(spec.id) != 0 && + spec.id.value <= typography_specimen_max_root_id() && + hit_geometry_valid(spec.bounds) != 0 && + spec.name.len > 0 && spec.name.len <= text_byte_limit() && + semantic_text_input_valid(spec.name) != 0 && + typography_specimen_sample_valid(spec.ramp_sample) != 0 && + typography_specimen_sample_valid(spec.weight_sample) != 0 && + typography_specimen_sample_valid(spec.contrast_sample) != 0 && + typography_specimen_density_valid(spec.density) != 0 && + typography_specimen_direction_valid(spec.direction) != 0 && + spec.text_scale >= unit_scale() && + spec.text_scale <= unit_scale() * 4) as i32; +} + +fn typography_specimen_scale_steps(text_scale: i64) i32 { + if (text_scale >= (unit_scale() * 7) / 4) { return 2; } + if (text_scale >= (unit_scale() * 5) / 4) { return 1; } + return 0; +} + +fn typography_specimen_upshift_once(token: SemanticTypeToken) + SemanticTypeToken { + return switch (token) { + .display => SemanticTypeToken.display, + .title => SemanticTypeToken.display, + .heading => SemanticTypeToken.title, + .body => SemanticTypeToken.heading, + .label => SemanticTypeToken.body, + .caption => SemanticTypeToken.label, + .code => SemanticTypeToken.body, + }; +} + +fn typography_specimen_resolve_type(token: SemanticTypeToken, steps: i32) + SemanticTypeToken { + let result: SemanticTypeToken = token; + let index: i32 = 0; + while (index < steps) { + result = typography_specimen_upshift_once(result); + index = index + 1; + } + return result; +} + +fn typography_specimen_section(index: i32) TypographySpecimenSection { + if (index < typography_specimen_weight_row_start()) { + return TypographySpecimenSection.type_ramp; + } + if (index < typography_specimen_contrast_row_start()) { + return TypographySpecimenSection.weight; + } + return TypographySpecimenSection.contrast; +} + +fn typography_specimen_requested_type(index: i32) SemanticTypeToken { + return switch (index) { + 0 => SemanticTypeToken.display, + 1 => SemanticTypeToken.title, + 2 => SemanticTypeToken.heading, + 3 => SemanticTypeToken.body, + 4 => SemanticTypeToken.label, + 5 => SemanticTypeToken.caption, + 6 => SemanticTypeToken.code, + else => SemanticTypeToken.body, + }; +} + +fn typography_specimen_weight(index: i32) TypographySpecimenWeight { + if (index == 7) { return TypographySpecimenWeight.light; } + if (index == 9) { return TypographySpecimenWeight.bold; } + return TypographySpecimenWeight.regular; +} + +fn typography_specimen_color(index: i32) SemanticColorToken { + if (index == 11) { return SemanticColorToken.text_secondary; } + if (index == 12) { return SemanticColorToken.text_disabled; } + return SemanticColorToken.text_primary; +} + +fn typography_specimen_disabled(index: i32) i32 { + return (index == 12) as i32; +} + +fn typography_specimen_label(index: i32) []u8 { + return switch (index) { + 0 => "DISPLAY", + 1 => "TITLE", + 2 => "HEADING", + 3 => "BODY", + 4 => "LABEL", + 5 => "CAPTION", + 6 => "CODE", + 7 => "LIGHT", + 8 => "REGULAR", + 9 => "BOLD", + 10 => "PRIMARY", + 11 => "SECONDARY", + else => "DISABLED", + }; +} + +fn typography_specimen_row_name(index: i32) []u8 { + return switch (index) { + 0 => "Display type specimen", + 1 => "Title type specimen", + 2 => "Heading type specimen", + 3 => "Body type specimen", + 4 => "Label type specimen", + 5 => "Caption type specimen", + 6 => "Code type specimen", + 7 => "Light weight specimen", + 8 => "Regular weight specimen", + 9 => "Bold weight specimen", + 10 => "Primary contrast specimen", + 11 => "Secondary contrast specimen", + else => "Disabled contrast specimen", + }; +} + +fn typography_specimen_row_description(index: i32) []u8 { + if (index < typography_specimen_type_row_count()) { + return "Canonical semantic type role and resolved scale role"; + } + if (index < typography_specimen_contrast_row_start()) { + return "Caller-declared face with distinct outline evidence"; + } + return "Canonical semantic text color tier"; +} + +fn typography_specimen_row_value(index: i32) []u8 { + if (index < typography_specimen_type_row_count()) { + return semantic_type_name(typography_specimen_requested_type(index)); + } + return switch (index) { + 7 => "weight.light / outline verified", + 8 => "weight.regular / outline verified", + 9 => "weight.bold / outline verified", + 10 => "color.text.primary", + 11 => "color.text.secondary", + else => "color.text.disabled", + }; +} + +fn typography_specimen_sample( + spec: @retained_by_return TypographySpecimenSpec, + index: i32) []u8 { + if (index < typography_specimen_type_row_count()) { + return spec.ramp_sample; + } + if (index < typography_specimen_contrast_row_start()) { + return spec.weight_sample; + } + return spec.contrast_sample; +} + +fn typography_specimen_id_value(root: NodeKey, offset: i64) i64 { + return root.value * 1000 + offset; +} + +fn typography_specimen_row_id(root: NodeKey, index: i32) NodeKey { + return node_key_scoped(typography_specimen_id_value(root, + 100 + (index as i64) * 10), root.generation); +} + +fn typography_specimen_label_id(root: NodeKey, index: i32) NodeKey { + return node_key_scoped(typography_specimen_id_value(root, + 101 + (index as i64) * 10), root.generation); +} + +fn typography_specimen_sample_id(root: NodeKey, index: i32) NodeKey { + return node_key_scoped(typography_specimen_id_value(root, + 102 + (index as i64) * 10), root.generation); +} + +fn typography_specimen_probe_id(root: NodeKey) NodeKey { + return node_key_scoped(typography_specimen_id_value(root, 900), + root.generation); +} + +fn typography_specimen_mix(hash: i64, value: i64) i64 { + return (hash * 1099511628211) ^ value; +} + +fn typography_specimen_payload_hash(artifact: *TextArtifact) i64 { + if (text_artifact_valid(artifact) == 0 || + artifact.*.canvas.display.resources.resources.len != 1) { return 0; } + let resource: RenderResource = + artifact.*.canvas.display.resources.resources.data[0]; + let hash: i64 = typography_specimen_mix(17, + resource.payload.len as i64); + let index: i32 = 0; + while (index < resource.payload.len) { + hash = typography_specimen_mix(hash, + resource.payload.data[index] as i64); + index = index + 1; + } + if (hash == 0) { return 1; } + return hash; +} + +fn typography_specimen_row_basis(index: i32, scale_steps: i32, + density: FlexDensity) i64 { + let requested: SemanticTypeToken = + typography_specimen_requested_type(index); + let resolved: SemanticTypeToken = typography_specimen_resolve_type( + requested, scale_steps); + let label: SemanticTypeToken = typography_specimen_resolve_type( + SemanticTypeToken.caption, scale_steps); + let content_height: i64 = semantic_type_size(resolved); + if (semantic_type_size(label) > content_height) { + content_height = semantic_type_size(label); + } + let padding: i64 = flex_spacing(FlexSpacingToken.small, density); + let basis: i64 = (content_height * 3) / 2 + padding * 2; + let minimum: i64 = 32 * unit_scale(); + if (basis < minimum) { return minimum; } + return basis; +} + +fn typography_specimen_required_height(text_scale: i64, + density: FlexDensity) i64 { + let steps: i32 = typography_specimen_scale_steps(text_scale); + let total: i64 = flex_spacing(FlexSpacingToken.large, density) * 2 + + flex_spacing(FlexSpacingToken.small, density) * + ((typography_specimen_row_count() - 1) as i64); + let index: i32 = 0; + while (index < typography_specimen_row_count()) { + total = total + typography_specimen_row_basis(index, steps, density); + index = index + 1; + } + return total; +} + +fn typography_specimen_structural_minimum_width( + density: FlexDensity) i64 { + return flex_spacing(FlexSpacingToken.large, density) * 2 + + flex_spacing(FlexSpacingToken.small, density) * 2 + + flex_spacing(FlexSpacingToken.large, density) + + 120 * unit_scale() + 160 * unit_scale(); +} + +fn typography_specimen_root_semantics( + artifact: *TypographySpecimenArtifact, + spec: TypographySpecimenSpec) i32 { + let root: SemanticsSpec = semantics_spec(spec.id, semantic_root_key(), + SemanticRole.group, spec.name); + root.description = + "Type ramp, weight, and contrast system conformance specimen"; + if (spec.direction == LayoutDirection.rtl) { + root.value = "RTL Flex layout; nominal LTR text only"; + } else { + root.value = "LTR Flex layout; nominal LTR text only"; + } + root.has_bounds = 1; + root.bounds = spec.bounds; + let error: SemanticsError = semantics_add(&artifact.*.semantics, root); + if (error == SemanticsError.none) { return 1; } + artifact.*.error = TypographySpecimenError.semantics_error; + artifact.*.semantics_error = error; + return 0; +} + +fn typography_specimen_row_semantics( + artifact: *TypographySpecimenArtifact, + index: i32, bounds: Rect) i32 { + let row: SemanticsSpec = semantics_spec( + typography_specimen_row_id(artifact.*.id, index), artifact.*.id, + SemanticRole.group, typography_specimen_row_name(index)); + row.description = typography_specimen_row_description(index); + row.value = typography_specimen_row_value(index); + row.has_bounds = 1; + row.bounds = bounds; + row.set_size = typography_specimen_row_count() as i64; + row.position_in_set = 1 + index as i64; + let error: SemanticsError = semantics_add(&artifact.*.semantics, row); + if (error == SemanticsError.none) { return 1; } + artifact.*.error = TypographySpecimenError.semantics_error; + artifact.*.semantics_error = error; + artifact.*.error_row = index; + return 0; +} + +fn typography_specimen_record_text_error( + artifact: *TypographySpecimenArtifact, + index: i32, error: TextError) void { + artifact.*.error = TypographySpecimenError.text_error; + artifact.*.text_error = error; + artifact.*.error_row = index; +} + +fn typography_specimen_record_contribution_error( + artifact: *TypographySpecimenArtifact, + index: i32, result: TextContributionResult) void { + artifact.*.error = TypographySpecimenError.contribution_error; + artifact.*.contribution_error = result.error; + artifact.*.semantics_error = result.semantics_error; + artifact.*.display_error = result.display_error; + artifact.*.error_row = index; +} + +fn typography_specimen_emit_row( + artifact: *TypographySpecimenArtifact, + spec: TypographySpecimenSpec, + regular_face: *OpenTypeFace, + sample_face: *OpenTypeFace, + index: i32, relative_bounds: Rect) i32 { + let bounds: Rect = rect(spec.bounds.x + relative_bounds.x, + spec.bounds.y + relative_bounds.y, + relative_bounds.width, relative_bounds.height); + let inner_items: ArrayList[FlexItem] = make[FlexItem](2); + let label_item: FlexItem = flex_item(1, 200 * unit_scale(), + relative_bounds.height); + label_item.min_main = 120 * unit_scale(); + label_item.max_main = 240 * unit_scale(); + label_item.grow = 0; + label_item.shrink = 1; + let sample_item: FlexItem = flex_item(2, 520 * unit_scale(), + relative_bounds.height); + sample_item.min_main = 160 * unit_scale(); + sample_item.grow = 1; + sample_item.shrink = 1; + push[FlexItem](&inner_items, label_item); + push[FlexItem](&inner_items, sample_item); + let inner_style: FlexStyle = flex_style(FlexAxis.row, + artifact.*.column_gap, insets_all(artifact.*.row_padding)); + inner_style.direction = spec.direction; + inner_style.cross_alignment = CrossAlignment.stretch; + let inner: FlexResult = flex_layout(inner_items, + tight_constraints(relative_bounds.width, relative_bounds.height), + inner_style); + free[FlexItem](&inner_items); + if (inner.error != FlexError.none) { + artifact.*.error = TypographySpecimenError.layout_error; + artifact.*.flex_error = inner.error; + artifact.*.error_row = index; + flex_free(&inner); + return 0; + } + if (inner.overflow != 0 || inner.placements.len != 2 || + inner.duplicate_ids != 0) { + artifact.*.error = TypographySpecimenError.layout_overflow; + artifact.*.error_row = index; + flex_free(&inner); + return 0; + } + let label_relative: Rect = inner.placements.data[0].frame; + let sample_relative: Rect = inner.placements.data[1].frame; + let label_bounds: Rect = rect(bounds.x + label_relative.x, + bounds.y + label_relative.y, + label_relative.width, label_relative.height); + let sample_bounds: Rect = rect(bounds.x + sample_relative.x, + bounds.y + sample_relative.y, + sample_relative.width, sample_relative.height); + flex_free(&inner); + + if (typography_specimen_row_semantics(artifact, index, bounds) == 0) { + return 0; + } + let requested: SemanticTypeToken = + typography_specimen_requested_type(index); + let resolved: SemanticTypeToken = typography_specimen_resolve_type( + requested, artifact.*.scale_steps); + let label_type: SemanticTypeToken = typography_specimen_resolve_type( + SemanticTypeToken.caption, artifact.*.scale_steps); + let label_alignment: TextAlignment = TextAlignment.end; + let sample_alignment: TextAlignment = TextAlignment.start; + if (spec.direction == LayoutDirection.rtl) { + label_alignment = TextAlignment.start; + sample_alignment = TextAlignment.end; + } + + let label_spec: TextSpec = text_spec( + typography_specimen_label_id(spec.id, index), + typography_specimen_row_id(spec.id, index), label_bounds, + typography_specimen_label(index), label_type, + SemanticColorToken.text_secondary); + label_spec.alignment = label_alignment; + let sample_spec: TextSpec = text_spec( + typography_specimen_sample_id(spec.id, index), + typography_specimen_row_id(spec.id, index), sample_bounds, + typography_specimen_sample(spec, index), resolved, + typography_specimen_color(index)); + sample_spec.alignment = sample_alignment; + sample_spec.disabled = typography_specimen_disabled(index); + + let label: TextArtifact = text_build(regular_face.*, label_spec); + if (label.error != TextError.none) { + typography_specimen_record_text_error(artifact, index, label.error); + text_free(&label); + return 0; + } + let sample: TextArtifact = text_build(sample_face.*, sample_spec); + if (sample.error != TextError.none) { + typography_specimen_record_text_error(artifact, index, sample.error); + text_free(&sample); + text_free(&label); + return 0; + } + let label_contribution: TextContributionResult = text_contribute( + &label, &artifact.*.display, &artifact.*.semantics); + if (label_contribution.error != TextContributionError.none) { + typography_specimen_record_contribution_error( + artifact, index, label_contribution); + text_free(&sample); + text_free(&label); + return 0; + } + let sample_contribution: TextContributionResult = text_contribute( + &sample, &artifact.*.display, &artifact.*.semantics); + if (sample_contribution.error != TextContributionError.none) { + typography_specimen_record_contribution_error( + artifact, index, sample_contribution); + text_free(&sample); + text_free(&label); + return 0; + } + if (label_contribution.display_operations_added != 5 || + label_contribution.display_resources_added != 1 || + label_contribution.semantic_nodes_added != 1 || + sample_contribution.display_operations_added != 5 || + sample_contribution.display_resources_added != 1 || + sample_contribution.semantic_nodes_added != 1) { + artifact.*.error = TypographySpecimenError.contribution_error; + artifact.*.error_row = index; + text_free(&sample); + text_free(&label); + return 0; + } + + push[TypographySpecimenRow](&artifact.*.rows, TypographySpecimenRow{ + .index = index, + .section = typography_specimen_section(index), + .row_id = typography_specimen_row_id(spec.id, index), + .label_id = typography_specimen_label_id(spec.id, index), + .sample_id = typography_specimen_sample_id(spec.id, index), + .bounds = bounds, + .label_bounds = label_bounds, + .sample_bounds = sample_bounds, + .requested_type = requested, + .resolved_type = resolved, + .label_type = label_type, + .weight = typography_specimen_weight(index), + .color = typography_specimen_color(index), + .label_alignment = label_alignment, + .sample_alignment = sample_alignment, + .disabled = typography_specimen_disabled(index), + .resolved_font_size = sample.font_size, + .resolved_label_font_size = label.font_size, + .label_evidence_hash = label.evidence_hash, + .sample_evidence_hash = sample.evidence_hash, + .sample_path_payload_hash = + typography_specimen_payload_hash(&sample), + }); + text_free(&sample); + text_free(&label); + return 1; +} + +fn typography_specimen_weight_probe( + artifact: *TypographySpecimenArtifact, + spec: TypographySpecimenSpec, + light_face: *OpenTypeFace, + regular_face: *OpenTypeFace, + bold_face: *OpenTypeFace) i32 { + let probe_spec: TextSpec = text_spec( + typography_specimen_probe_id(spec.id), semantic_root_key(), + rect(0, 0, 720 * unit_scale(), 96 * unit_scale()), + spec.weight_sample, SemanticTypeToken.body, + SemanticColorToken.text_primary); + let light: TextArtifact = text_build(light_face.*, probe_spec); + let regular: TextArtifact = text_build(regular_face.*, probe_spec); + let bold: TextArtifact = text_build(bold_face.*, probe_spec); + if (light.error != TextError.none || regular.error != TextError.none || + bold.error != TextError.none) { + artifact.*.error = TypographySpecimenError.text_error; + artifact.*.text_error = light.error; + if (artifact.*.text_error == TextError.none) { + artifact.*.text_error = regular.error; + } + if (artifact.*.text_error == TextError.none) { + artifact.*.text_error = bold.error; + } + artifact.*.error_row = typography_specimen_weight_row_start(); + text_free(&bold); + text_free(®ular); + text_free(&light); + return 0; + } + artifact.*.light_probe_hash = typography_specimen_payload_hash(&light); + artifact.*.regular_probe_hash = typography_specimen_payload_hash(®ular); + artifact.*.bold_probe_hash = typography_specimen_payload_hash(&bold); + text_free(&bold); + text_free(®ular); + text_free(&light); + if (artifact.*.light_probe_hash == 0 || + artifact.*.regular_probe_hash == 0 || + artifact.*.bold_probe_hash == 0 || + artifact.*.light_probe_hash == artifact.*.regular_probe_hash || + artifact.*.light_probe_hash == artifact.*.bold_probe_hash || + artifact.*.regular_probe_hash == artifact.*.bold_probe_hash) { + artifact.*.error = TypographySpecimenError.weight_not_visible; + artifact.*.error_row = typography_specimen_weight_row_start(); + return 0; + } + artifact.*.weight_shape_proof = 1; + return 1; +} + +fn typography_specimen_row_hash(hash: i64, + row: TypographySpecimenRow) i64 { + let result: i64 = typography_specimen_mix(hash, row.index as i64); + result = typography_specimen_mix(result, row.section as i64); + result = typography_specimen_mix(result, row.row_id.value); + result = typography_specimen_mix(result, row.row_id.generation); + result = typography_specimen_mix(result, row.label_id.value); + result = typography_specimen_mix(result, row.sample_id.value); + result = typography_specimen_mix(result, row.bounds.x); + result = typography_specimen_mix(result, row.bounds.y); + result = typography_specimen_mix(result, row.bounds.width); + result = typography_specimen_mix(result, row.bounds.height); + result = typography_specimen_mix(result, row.label_bounds.x); + result = typography_specimen_mix(result, row.label_bounds.y); + result = typography_specimen_mix(result, row.label_bounds.width); + result = typography_specimen_mix(result, row.label_bounds.height); + result = typography_specimen_mix(result, row.sample_bounds.x); + result = typography_specimen_mix(result, row.sample_bounds.y); + result = typography_specimen_mix(result, row.sample_bounds.width); + result = typography_specimen_mix(result, row.sample_bounds.height); + result = typography_specimen_mix(result, row.requested_type as i64); + result = typography_specimen_mix(result, row.resolved_type as i64); + result = typography_specimen_mix(result, row.label_type as i64); + result = typography_specimen_mix(result, row.weight as i64); + result = typography_specimen_mix(result, row.color as i64); + result = typography_specimen_mix(result, row.label_alignment as i64); + result = typography_specimen_mix(result, row.sample_alignment as i64); + result = typography_specimen_mix(result, row.disabled as i64); + result = typography_specimen_mix(result, row.resolved_font_size); + result = typography_specimen_mix(result, row.resolved_label_font_size); + result = typography_specimen_mix(result, row.label_evidence_hash); + result = typography_specimen_mix(result, row.sample_evidence_hash); + return typography_specimen_mix(result, row.sample_path_payload_hash); +} + +fn typography_specimen_calculate_hash( + artifact: *TypographySpecimenArtifact) i64 { + let hash: i64 = typography_specimen_mix(17, artifact.*.id.value); + hash = typography_specimen_mix(hash, artifact.*.id.generation); + hash = typography_specimen_mix(hash, artifact.*.bounds.x); + hash = typography_specimen_mix(hash, artifact.*.bounds.y); + hash = typography_specimen_mix(hash, artifact.*.bounds.width); + hash = typography_specimen_mix(hash, artifact.*.bounds.height); + hash = typography_specimen_mix(hash, artifact.*.density as i64); + hash = typography_specimen_mix(hash, artifact.*.direction as i64); + hash = typography_specimen_mix(hash, artifact.*.text_scale); + hash = typography_specimen_mix(hash, artifact.*.scale_steps as i64); + hash = typography_specimen_mix(hash, artifact.*.outer_padding); + hash = typography_specimen_mix(hash, artifact.*.row_gap); + hash = typography_specimen_mix(hash, artifact.*.row_padding); + hash = typography_specimen_mix(hash, artifact.*.column_gap); + hash = typography_specimen_mix(hash, artifact.*.required_height); + hash = typography_specimen_mix(hash, + artifact.*.structural_minimum_width); + hash = typography_specimen_mix(hash, + artifact.*.rtl_layout_applied as i64); + hash = typography_specimen_mix(hash, + artifact.*.nominal_ltr_text_only as i64); + hash = typography_specimen_mix(hash, + artifact.*.bidi_shaping_available as i64); + hash = typography_specimen_mix(hash, + artifact.*.font_fallback_available as i64); + hash = typography_specimen_mix(hash, + artifact.*.continuous_text_scaling_available as i64); + hash = typography_specimen_mix(hash, + artifact.*.code_family_routing_available as i64); + hash = typography_specimen_mix(hash, + artifact.*.weight_metadata_verified as i64); + hash = typography_specimen_mix(hash, + artifact.*.weight_shape_proof as i64); + hash = typography_specimen_mix(hash, artifact.*.light_probe_hash); + hash = typography_specimen_mix(hash, artifact.*.regular_probe_hash); + hash = typography_specimen_mix(hash, artifact.*.bold_probe_hash); + hash = typography_specimen_mix(hash, artifact.*.display_hash); + hash = typography_specimen_mix(hash, artifact.*.talkback_hash); + let index: i32 = 0; + while (index < artifact.*.rows.len) { + hash = typography_specimen_row_hash(hash, + artifact.*.rows.data[index]); + index = index + 1; + } + if (hash == 0) { return 1; } + return hash; +} + +fn typography_specimen_build( + spec: TypographySpecimenSpec, + light_face: *OpenTypeFace, + regular_face: *OpenTypeFace, + bold_face: *OpenTypeFace) TypographySpecimenArtifact { + let artifact: TypographySpecimenArtifact = + typography_specimen_empty(spec); + if (typography_specimen_spec_valid(spec) == 0) { + artifact.error = TypographySpecimenError.invalid_spec; + return artifact; + } + if (text_font_valid(light_face.*) == 0) { + artifact.error = TypographySpecimenError.missing_font; + artifact.missing_face_index = 0; + return artifact; + } + if (text_font_valid(regular_face.*) == 0) { + artifact.error = TypographySpecimenError.missing_font; + artifact.missing_face_index = 1; + return artifact; + } + if (text_font_valid(bold_face.*) == 0) { + artifact.error = TypographySpecimenError.missing_font; + artifact.missing_face_index = 2; + return artifact; + } + + artifact.scale_steps = typography_specimen_scale_steps(spec.text_scale); + artifact.outer_padding = flex_spacing(FlexSpacingToken.large, + spec.density); + artifact.row_gap = flex_spacing(FlexSpacingToken.small, spec.density); + artifact.row_padding = flex_spacing(FlexSpacingToken.small, + spec.density); + artifact.column_gap = flex_spacing(FlexSpacingToken.large, spec.density); + artifact.required_height = typography_specimen_required_height( + spec.text_scale, spec.density); + artifact.structural_minimum_width = + typography_specimen_structural_minimum_width(spec.density); + artifact.rtl_layout_applied = + (spec.direction == LayoutDirection.rtl) as i32; + + let outer_items: ArrayList[FlexItem] = make[FlexItem]( + typography_specimen_row_count()); + let item_index: i32 = 0; + while (item_index < typography_specimen_row_count()) { + let basis: i64 = typography_specimen_row_basis(item_index, + artifact.scale_steps, spec.density); + let item: FlexItem = flex_item(1 + item_index as i64, basis, + spec.bounds.width); + item.min_main = basis; + item.max_main = basis; + item.grow = 0; + item.shrink = 0; + push[FlexItem](&outer_items, item); + item_index = item_index + 1; + } + let outer_style: FlexStyle = flex_style(FlexAxis.column, + artifact.row_gap, insets_all(artifact.outer_padding)); + outer_style.direction = spec.direction; + outer_style.cross_alignment = CrossAlignment.stretch; + let outer: FlexResult = flex_layout(outer_items, + tight_constraints(spec.bounds.width, spec.bounds.height), + outer_style); + free[FlexItem](&outer_items); + if (outer.error != FlexError.none) { + artifact.error = TypographySpecimenError.layout_error; + artifact.flex_error = outer.error; + flex_free(&outer); + return artifact; + } + if (outer.overflow != 0 || outer.placements.len != + typography_specimen_row_count() || outer.duplicate_ids != 0) { + artifact.error = TypographySpecimenError.layout_overflow; + flex_free(&outer); + return artifact; + } + if (typography_specimen_weight_probe(&artifact, spec, + light_face, regular_face, bold_face) == 0) { + flex_free(&outer); + return artifact; + } + if (typography_specimen_root_semantics(&artifact, spec) == 0) { + flex_free(&outer); + return artifact; + } + + let row_index: i32 = 0; + while (row_index < typography_specimen_row_count()) { + let emitted: i32 = 0; + if (typography_specimen_weight(row_index) == + TypographySpecimenWeight.light) { + emitted = typography_specimen_emit_row(&artifact, spec, + regular_face, light_face, row_index, + outer.placements.data[row_index].frame); + } else if (typography_specimen_weight(row_index) == + TypographySpecimenWeight.bold) { + emitted = typography_specimen_emit_row(&artifact, spec, + regular_face, bold_face, row_index, + outer.placements.data[row_index].frame); + } else { + emitted = typography_specimen_emit_row(&artifact, spec, + regular_face, regular_face, row_index, + outer.placements.data[row_index].frame); + } + if (emitted == 0) { + flex_free(&outer); + return artifact; + } + row_index = row_index + 1; + } + flex_free(&outer); + + let sealed: DisplayError = display_list_seal(&artifact.display); + if (sealed != DisplayError.none) { + artifact.error = TypographySpecimenError.display_error; + artifact.display_error = sealed; + return artifact; + } + let verified: DisplayError = display_list_verify(&artifact.display); + if (verified != DisplayError.none) { + artifact.error = TypographySpecimenError.display_error; + artifact.display_error = verified; + return artifact; + } + artifact.display_hash = artifact.display.content_hash; + artifact.talkback_hash = talkback_tree_hash(artifact.semantics); + artifact.evidence_hash = typography_specimen_calculate_hash(&artifact); + return artifact; +} + +fn typography_specimen_valid( + artifact: *TypographySpecimenArtifact) i32 { + if (artifact.*.error != TypographySpecimenError.none || + artifact.*.rows.len != typography_specimen_row_count() || + artifact.*.semantics.nodes.len != + 1 + typography_specimen_row_count() * 3 || + artifact.*.display.ops.len != typography_specimen_row_count() * 10 || + artifact.*.display.resources.resources.len != + typography_specimen_row_count() * 2 || + artifact.*.weight_shape_proof == 0 || + artifact.*.rtl_layout_applied != + (artifact.*.direction == LayoutDirection.rtl) as i32 || + artifact.*.nominal_ltr_text_only != 1 || + artifact.*.bidi_shaping_available != 0 || + artifact.*.font_fallback_available != 0 || + artifact.*.continuous_text_scaling_available != 0 || + artifact.*.code_family_routing_available != 0 || + artifact.*.weight_metadata_verified != 0 || + artifact.*.required_height <= 0 || + artifact.*.structural_minimum_width <= 0 || + artifact.*.display_hash == 0 || artifact.*.talkback_hash == 0 || + artifact.*.evidence_hash == 0 || + display_list_verify(&artifact.*.display) != DisplayError.none || + artifact.*.display_hash != artifact.*.display.content_hash || + artifact.*.talkback_hash != + talkback_tree_hash(artifact.*.semantics) || + artifact.*.evidence_hash != + typography_specimen_calculate_hash(artifact)) { return 0; } + return 1; +} + +fn typography_specimen_host_spec(semantic_parent: NodeKey) + TypographySpecimenHostSpec { + return TypographySpecimenHostSpec{ + .semantic_parent = semantic_parent, + }; +} + +fn typography_specimen_host_result() TypographySpecimenHostResult { + return TypographySpecimenHostResult{ + .error = TypographySpecimenHostError.none, + .resource_error = RenderResourceError.none, + .display_error = DisplayError.none, + .semantics_error = SemanticsError.none, + .display_operations_added = 0, + .display_resources_added = 0, + .semantic_nodes_added = 0, + }; +} + +fn typography_specimen_host_resource_copy(resource: RenderResource) + RenderResource { + return RenderResource{ + .id = resource.id, + .kind = resource.kind, + .payload = render_resource_payload_copy( + resource.payload.data[0..resource.payload.len]), + .revision = resource.revision, + .format_tag = resource.format_tag, + .width = resource.width, + .height = resource.height, + .color_space = resource.color_space, + .content_hash = resource.content_hash, + }; +} + +fn typography_specimen_host_stage_display(source: *DisplayList) + TypographySpecimenDisplayStage { + let staged: DisplayList = display_list_make_with_resource_limits( + source.*.resources.max_resources, + source.*.resources.max_total_bytes, + source.*.resources.max_payload_bytes); + let result: TypographySpecimenDisplayStage = + TypographySpecimenDisplayStage{ + .display = staged, + .valid = 0, + }; + if (source.*.sealed != 0 || source.*.resources.sealed != 0 || + source.*.last_error != DisplayError.none || + source.*.resources.last_error != RenderResourceError.none || + source.*.error_index != 0 - 1 || + source.*.resources.error_id != 0 || + render_resource_store_configuration_valid(source.*.resources) == 0 || + source.*.stack_depth < 0 || source.*.layer_depth < 0) { + return result; + } + let total: i64 = 0; + let resource_index: i32 = 0; + while (resource_index < source.*.resources.resources.len) { + let resource: RenderResource = + source.*.resources.resources.data[resource_index]; + if (resource.id <= 0 || resource.payload.len <= 0 || + render_resource_hash(resource) != resource.content_hash || + (resource_index > 0 && source.*.resources.resources.data[ + resource_index - 1].id >= resource.id)) { + return result; + } + push[RenderResource](&result.display.resources.resources, + typography_specimen_host_resource_copy(resource)); + total = total + resource.payload.len as i64; + resource_index = resource_index + 1; + } + if (total != source.*.resources.total_bytes || + render_resource_store_hash(&source.*.resources) != + source.*.resources.content_hash) { return result; } + result.display.resources.total_bytes = source.*.resources.total_bytes; + result.display.resources.revision = source.*.resources.revision; + result.display.resources.content_hash = source.*.resources.content_hash; + result.display.resources.last_error = RenderResourceError.none; + result.display.resources.error_id = 0; + result.display.content_hash = display_mix(17, + result.display.resources.content_hash); + result.display.revision = result.display.resources.revision; + let operation_index: i32 = 0; + while (operation_index < source.*.ops.len) { + if (display_list_push(&result.display, + source.*.ops.data[operation_index]) != DisplayError.none) { + return result; + } + operation_index = operation_index + 1; + } + if (result.display.stack_depth != source.*.stack_depth || + result.display.layer_depth != source.*.layer_depth || + result.display.revision != source.*.revision || + result.display.content_hash != source.*.content_hash || + display_list_calculate_hash(&result.display) != + source.*.content_hash) { return result; } + result.valid = 1; + return result; +} + +fn typography_specimen_host_semantic_spec( + node: @retained_by_return SemanticsNode, + parent: NodeKey) + SemanticsSpec { + return SemanticsSpec{ + .id = node.id, + .parent = parent, + .role = node.role, + .name = node.name.data[0..node.name.len], + .description = node.description.data[0..node.description.len], + .value = node.value.data[0..node.value.len], + .action_mask = node.action_mask, + .focus_order = node.focus_order, + .disabled = node.disabled, + .selected = node.selected, + .focused = node.focused, + .hidden = node.hidden, + .live_region = node.live_region, + .has_range = node.has_range, + .range_min = node.range_min, + .range_max = node.range_max, + .range_value = node.range_value, + .range_step = node.range_step, + .text_length = node.text_length, + .selection_start = node.selection_start, + .selection_end = node.selection_end, + .has_bounds = node.has_bounds, + .bounds = node.bounds, + .labelled_by = node.labelled_by, + .described_by = node.described_by, + .controls = node.controls, + .row_count = node.row_count, + .column_count = node.column_count, + .row_index = node.row_index, + .column_index = node.column_index, + .row_span = node.row_span, + .column_span = node.column_span, + .level = node.level, + .set_size = node.set_size, + .position_in_set = node.position_in_set, + .expanded = node.expanded, + }; +} + +fn typography_specimen_host_semantics_preflight( + artifact: *TypographySpecimenArtifact, + host: TypographySpecimenHostSpec, + destination: *SemanticsTree) TypographySpecimenHostError { + if (node_key_equal(host.semantic_parent, semantic_none_key()) != 0 || + (node_key_equal(host.semantic_parent, semantic_root_key()) == 0 && + canvas_key_valid(host.semantic_parent) == 0)) { + return TypographySpecimenHostError.invalid_semantic_parent; + } + if (node_key_equal(host.semantic_parent, semantic_root_key()) == 0 && + semantics_find_index_borrowed(destination, + host.semantic_parent) < 0) { + return TypographySpecimenHostError.missing_semantic_parent; + } + let root_found: i32 = 0; + let index: i32 = 0; + while (index < artifact.*.semantics.nodes.len) { + let source: SemanticsNode = artifact.*.semantics.nodes.data[index]; + if (semantics_find_index_borrowed(destination, source.id) >= 0) { + return TypographySpecimenHostError.duplicate_semantic_id; + } + if (node_key_equal(source.id, artifact.*.id) != 0) { + if (root_found != 0 || index != 0 || + node_key_equal(source.parent, semantic_root_key()) == 0) { + return TypographySpecimenHostError.invalid_artifact; + } + root_found = 1; + } else { + let parent_index: i32 = semantics_find_index_borrowed( + &artifact.*.semantics, source.parent); + if (node_key_equal(source.parent, semantic_root_key()) != 0 || + parent_index < 0 || parent_index >= index) { + return TypographySpecimenHostError.invalid_artifact; + } + } + index = index + 1; + } + if (root_found == 0) { + return TypographySpecimenHostError.invalid_artifact; + } + return TypographySpecimenHostError.none; +} + +fn typography_specimen_host_display_preflight( + artifact: *TypographySpecimenArtifact, + destination: *DisplayList) TypographySpecimenHostError { + if (destination.*.sealed != 0 || destination.*.resources.sealed != 0 || + destination.*.last_error != DisplayError.none || + destination.*.resources.last_error != RenderResourceError.none || + render_resource_store_configuration_valid( + destination.*.resources) == 0 || + destination.*.stack_depth < 0 || destination.*.layer_depth < 0) { + return TypographySpecimenHostError.invalid_destination; + } + if (destination.*.resources.resources.len > + destination.*.resources.max_resources - + artifact.*.display.resources.resources.len || + artifact.*.display.resources.total_bytes > + destination.*.resources.max_total_bytes - + destination.*.resources.total_bytes) { + return TypographySpecimenHostError.invalid_destination; + } + let index: i32 = 0; + while (index < artifact.*.display.resources.resources.len) { + let resource: RenderResource = + artifact.*.display.resources.resources.data[index]; + if (render_resource_find_index(&destination.*.resources, + resource.id) >= 0) { + return TypographySpecimenHostError.resource_collision; + } + if (resource.payload.len as i64 > + destination.*.resources.max_payload_bytes) { + return TypographySpecimenHostError.invalid_destination; + } + index = index + 1; + } + return TypographySpecimenHostError.none; +} + +fn typography_specimen_host_add_display( + artifact: *TypographySpecimenArtifact, + destination: *DisplayList) + TypographySpecimenHostResult { + let result: TypographySpecimenHostResult = + typography_specimen_host_result(); + let resource_index: i32 = 0; + while (resource_index < artifact.*.display.resources.resources.len) { + let source: RenderResource = + artifact.*.display.resources.resources.data[resource_index]; + let spec: RenderResourceSpec = render_resource_spec(source.id, + source.kind, source.payload.data[0..source.payload.len], + source.format_tag); + spec.revision = source.revision; + spec.width = source.width; + spec.height = source.height; + spec.color_space = source.color_space; + let error: RenderResourceError = display_list_add_resource( + destination, spec); + if (error != RenderResourceError.none) { + result.error = TypographySpecimenHostError.resource_error; + result.resource_error = error; + return result; + } + result.display_resources_added = + result.display_resources_added + 1; + resource_index = resource_index + 1; + } + let operation_index: i32 = 0; + while (operation_index < artifact.*.display.ops.len) { + let error: DisplayError = display_list_push(destination, + artifact.*.display.ops.data[operation_index]); + if (error != DisplayError.none) { + result.error = TypographySpecimenHostError.display_error; + result.display_error = error; + return result; + } + result.display_operations_added = + result.display_operations_added + 1; + operation_index = operation_index + 1; + } + return result; +} + +fn typography_specimen_host_add_semantics( + artifact: *TypographySpecimenArtifact, + host: TypographySpecimenHostSpec, + destination: *SemanticsTree) SemanticsError { + let index: i32 = 0; + while (index < artifact.*.semantics.nodes.len) { + let source: SemanticsNode = artifact.*.semantics.nodes.data[index]; + let parent: NodeKey = source.parent; + if (node_key_equal(source.id, artifact.*.id) != 0) { + parent = host.semantic_parent; + } + let spec: SemanticsSpec = + typography_specimen_host_semantic_spec(source, parent); + let error: SemanticsError = semantics_add(destination, spec); + if (error != SemanticsError.none) { return error; } + index = index + 1; + } + return SemanticsError.none; +} + +// Atomically contributes one already-built read-only specimen into unsealed +// caller destinations. Only the specimen semantic root is reparented; exact +// child IDs, Flex bounds, display owners, resources, and operations survive. +fn typography_specimen_contribute_hosted( + artifact: *TypographySpecimenArtifact, + host: TypographySpecimenHostSpec, + display: *DisplayList, + semantics: *SemanticsTree) + TypographySpecimenHostResult { + let result: TypographySpecimenHostResult = + typography_specimen_host_result(); + if (typography_specimen_valid(artifact) == 0) { + result.error = TypographySpecimenHostError.invalid_artifact; + return result; + } + let semantic_preflight: TypographySpecimenHostError = + typography_specimen_host_semantics_preflight( + artifact, host, semantics); + if (semantic_preflight != TypographySpecimenHostError.none) { + result.error = semantic_preflight; + return result; + } + let display_preflight: TypographySpecimenHostError = + typography_specimen_host_display_preflight(artifact, display); + if (display_preflight != TypographySpecimenHostError.none) { + result.error = display_preflight; + return result; + } + let staged: TypographySpecimenDisplayStage = + typography_specimen_host_stage_display(display); + if (staged.valid == 0) { + display_list_free(&staged.display); + result.error = TypographySpecimenHostError.invalid_destination; + return result; + } + let stack_before: i32 = staged.display.stack_depth; + let layer_before: i32 = staged.display.layer_depth; + let display_added: TypographySpecimenHostResult = + typography_specimen_host_add_display(artifact, &staged.display); + if (display_added.error != TypographySpecimenHostError.none || + staged.display.stack_depth != stack_before || + staged.display.layer_depth != layer_before) { + result.error = display_added.error; + result.resource_error = display_added.resource_error; + result.display_error = display_added.display_error; + if (result.error == TypographySpecimenHostError.none) { + result.error = TypographySpecimenHostError.display_error; + if (staged.display.stack_depth != stack_before) { + result.display_error = DisplayError.unbalanced_stack; + } else { + result.display_error = DisplayError.unbalanced_layers; + } + } + display_list_free(&staged.display); + return result; + } + + let checkpoint: SemanticsCheckpoint = + semantics_tree_checkpoint(semantics); + let semantic_error: SemanticsError = + typography_specimen_host_add_semantics(artifact, host, semantics); + if (semantic_error != SemanticsError.none) { + _ = semantics_tree_rollback(semantics, checkpoint); + display_list_free(&staged.display); + result.error = TypographySpecimenHostError.semantics_error; + result.semantics_error = semantic_error; + return result; + } + + let previous: DisplayList = display.*; + display.* = staged.display; + display_list_free(&previous); + result.display_operations_added = + display_added.display_operations_added; + result.display_resources_added = + display_added.display_resources_added; + result.semantic_nodes_added = + semantics.*.nodes.len - checkpoint.length; + return result; +} + +fn typography_specimen_free( + artifact: *TypographySpecimenArtifact) void { + free[TypographySpecimenRow](&artifact.*.rows); + semantics_tree_free(&artifact.*.semantics); + display_list_free(&artifact.*.display); + artifact.*.display_hash = 0; + artifact.*.talkback_hash = 0; + artifact.*.evidence_hash = 0; +} diff --git a/tests/component_state_gallery_contract.zag b/tests/component_state_gallery_contract.zag new file mode 100644 index 0000000..f3408cf --- /dev/null +++ b/tests/component_state_gallery_contract.zag @@ -0,0 +1,1069 @@ +@import("../src/showcase/component_state_gallery.zag") +@import("../src/render/cpu_raster.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, + condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn gallery_test_finish(state: *TestState) i32 { + _zag_print("Component state gallery contract: pass="); + _zag_print_i64(state.*.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.*.failed as i64); + _zag_println(""); + if (state.*.failed == 0) { return 0; } + return 1; +} + +fn gallery_test_rect_equal(left: Rect, right: Rect) i32 { + return (left.x == right.x && left.y == right.y && + left.width == right.width && left.height == right.height) as i32; +} + +fn gallery_test_face(path: []u8) OpenTypeFace { + let bytes: []u8 = _zag_read_file(path); + if (bytes.len <= 0) { return opentype_face_empty(); } + let face: OpenTypeFace = opentype_parse(bytes); + _zag_free(_zag_slice_ptr(bytes)); + return face; +} + +fn gallery_test_spec() ComponentStateGallerySpec { + return component_state_gallery_spec(node_key_scoped(8200, 6), + rect(0, 0, 1440 * unit_scale(), 1280 * unit_scale())); +} + +fn gallery_test_owner_has_path(display: *DisplayList, + owner: NodeKey) i32 { + let index: i32 = 0; + while (index < display.*.ops.len) { + if (display.*.ops.data[index].kind == DisplayOpKind.draw_path && + node_key_equal(display.*.ops.data[index].owner, owner) != 0) { + return 1; + } + index = index + 1; + } + return 0; +} + +fn gallery_test_owner_has_operation(display: *DisplayList, + owner: NodeKey) i32 { + let index: i32 = 0; + while (index < display.*.ops.len) { + if (node_key_equal(display.*.ops.data[index].owner, owner) != 0) { + return 1; + } + index = index + 1; + } + return 0; +} + +fn gallery_test_owner_range(display: *DisplayList, + start: i32, end: i32, owner: NodeKey) i32 { + if (start < 0 || end <= start || end > display.*.ops.len) { return 0; } + let index: i32 = start; + while (index < end) { + if (node_key_equal(display.*.ops.data[index].owner, owner) == 0) { + return 0; + } + index = index + 1; + } + return 1; +} + +fn gallery_test_structure_contract(state: *TestState, + gallery: *ComponentStateGalleryArtifact) void { + expect(state, component_state_gallery_valid(gallery) != 0 && + gallery.*.tier_surfaces.len == 4 && + gallery.*.state_surfaces.len == 8 && gallery.*.buttons.len == 8 && + gallery.*.navigation_items.len == 6 && + gallery.*.segmented.segment_count == 4 && + gallery.*.frozen_conformance == 1 && + gallery.*.runtime_hash == component_state_gallery_runtime_state_hash( + gallery.*.runtime_state), + "one reusable artifact owns every required canonical component fixture"); + expect(state, gallery.*.display.ops.len == 407 && + gallery.*.display.resources.resources.len == 61 && + gallery.*.hits.nodes.len == 27 && + gallery.*.semantics.nodes.len == 64 && + gallery.*.text_artifact_count == 61 && + gallery.*.text_semantic_count == 32, + "the complete gallery has exact retained display resource hit and semantic counts"); + expect(state, gallery.*.layout.required_height == 1192 * unit_scale() && + gallery.*.layout.minimum_width == 1072 * unit_scale() && + component_state_gallery_required_height(FlexDensity.compact) == + 1119 * unit_scale() && + component_state_gallery_minimum_width(FlexDensity.compact) == + 1044 * unit_scale() && + component_state_gallery_required_height(FlexDensity.touch) == + 1265 * unit_scale() && + component_state_gallery_minimum_width(FlexDensity.touch) == + 1100 * unit_scale() && + gallery.*.bounds.height > gallery.*.layout.required_height && + gallery.*.bounds.width > gallery.*.layout.minimum_width, + "every density has explicit fixed-column Flex useful bounds"); + expect(state, gallery.*.display.sealed != 0 && + display_list_verify(&gallery.*.display) == DisplayError.none && + gallery.*.display_hash == gallery.*.display.content_hash && + gallery.*.hit_hash != 0 && gallery.*.talkback_hash != 0 && + gallery.*.evidence_hash != 0, + "the gallery seals into immutable deterministic display and automation evidence"); +} + +fn gallery_test_geometry_contract(state: *TestState, + gallery: *ComponentStateGalleryArtifact) void { + let exact: i32 = 1; + let caption_truth: i32 = 1; + let index: i32 = 0; + while (index < gallery.*.layout.tier_placements.len) { + let placement: ComponentStateGalleryPlacement = + gallery.*.layout.tier_placements.data[index]; + let surface: SurfaceArtifact = gallery.*.tier_surfaces.data[index]; + let semantic_index: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, placement.id); + let caption_index: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, placement.caption_id); + if (gallery_test_rect_equal(surface.bounds, + placement.control_bounds) == 0 || semantic_index < 0 || + gallery_test_owner_range(&gallery.*.display, + surface.display_op_start, surface.display_op_end, + placement.id) == 0 || + gallery_test_rect_equal( + gallery.*.semantics.nodes.data[semantic_index].bounds, + placement.control_bounds) == 0) { exact = 0; } + if (caption_index < 0 || gallery_test_rect_equal( + gallery.*.semantics.nodes.data[caption_index].bounds, + placement.caption_bounds) == 0 || + gallery_test_owner_has_path(&gallery.*.display, + placement.caption_id) == 0) { caption_truth = 0; } + index = index + 1; + } + index = 0; + while (index < gallery.*.layout.surface_placements.len) { + let placement: ComponentStateGalleryPlacement = + gallery.*.layout.surface_placements.data[index]; + let surface: SurfaceArtifact = gallery.*.state_surfaces.data[index]; + let hit_index: i32 = hit_tree_find_index_borrowed( + &gallery.*.hits, placement.id); + let semantic_index: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, placement.id); + let caption_index: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, placement.caption_id); + if (gallery_test_rect_equal(surface.bounds, + placement.control_bounds) == 0 || hit_index < 0 || + semantic_index < 0 || gallery_test_owner_range( + &gallery.*.display, surface.display_op_start, + surface.display_op_end, placement.id) == 0 || + gallery_test_rect_equal( + gallery.*.hits.nodes.data[hit_index].bounds, + placement.control_bounds) == 0 || + gallery_test_rect_equal( + gallery.*.semantics.nodes.data[semantic_index].bounds, + placement.control_bounds) == 0) { exact = 0; } + if (caption_index < 0 || gallery_test_rect_equal( + gallery.*.semantics.nodes.data[caption_index].bounds, + placement.caption_bounds) == 0) { caption_truth = 0; } + index = index + 1; + } + index = 0; + while (index < gallery.*.layout.button_placements.len) { + let placement: ComponentStateGalleryPlacement = + gallery.*.layout.button_placements.data[index]; + let button: ButtonArtifact = gallery.*.buttons.data[index]; + let hit_index: i32 = hit_tree_find_index_borrowed( + &gallery.*.hits, placement.id); + let semantic_index: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, placement.id); + if (gallery_test_rect_equal(button.bounds, + placement.control_bounds) == 0 || + hit_index < 0 || semantic_index < 0 || + gallery_test_rect_equal( + gallery.*.hits.nodes.data[hit_index].bounds, + placement.control_bounds) == 0 || + gallery_test_rect_equal( + gallery.*.semantics.nodes.data[semantic_index].bounds, + placement.control_bounds) == 0 || + gallery_test_owner_range(&gallery.*.display, + button.display_op_start, button.display_op_end, + placement.id) == 0) { exact = 0; } + if (semantics_find_index_borrowed(&gallery.*.semantics, + placement.caption_id) < 0) { caption_truth = 0; } + index = index + 1; + } + index = 0; + while (index < gallery.*.layout.navigation_placements.len) { + let placement: ComponentStateGalleryPlacement = + gallery.*.layout.navigation_placements.data[index]; + let navigation: NavigationItemArtifact = + gallery.*.navigation_items.data[index]; + let hit_index: i32 = hit_tree_find_index_borrowed( + &gallery.*.hits, placement.id); + let semantic_index: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, placement.id); + if (gallery_test_rect_equal(navigation.bounds, + placement.control_bounds) == 0 || + hit_index < 0 || semantic_index < 0 || + gallery_test_rect_equal( + gallery.*.hits.nodes.data[hit_index].bounds, + placement.control_bounds) == 0 || + gallery_test_rect_equal( + gallery.*.semantics.nodes.data[semantic_index].bounds, + placement.control_bounds) == 0 || + gallery_test_owner_range(&gallery.*.display, + navigation.display_op_start, navigation.display_op_end, + placement.id) == 0) { exact = 0; } + if (semantics_find_index_borrowed(&gallery.*.semantics, + placement.caption_id) < 0) { caption_truth = 0; } + index = index + 1; + } + expect(state, exact != 0, + "one Flex placement graph supplies exact component display hit and semantic geometry"); + expect(state, caption_truth != 0, + "every fixture state caption is both a visible Text path and semantic node"); +} + +fn gallery_test_semantic_reading_order(state: *TestState, + gallery: *ComponentStateGalleryArtifact) void { + let title: i32 = semantics_find_index_borrowed(&gallery.*.semantics, + component_state_gallery_title_id(gallery.*.id)); + let heading0: i32 = semantics_find_index_borrowed(&gallery.*.semantics, + component_state_gallery_heading_id(gallery.*.id, 0)); + let tier_first: i32 = semantics_find_index_borrowed(&gallery.*.semantics, + component_state_gallery_tier_caption_id(gallery.*.id, 0)); + let tier_last: i32 = semantics_find_index_borrowed(&gallery.*.semantics, + component_state_gallery_tier_id(gallery.*.id, 3)); + let heading1: i32 = semantics_find_index_borrowed(&gallery.*.semantics, + component_state_gallery_heading_id(gallery.*.id, 1)); + let surface_first: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, + component_state_gallery_surface_caption_id(gallery.*.id, 0)); + let surface_last: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, + component_state_gallery_surface_id(gallery.*.id, 7)); + let heading2: i32 = semantics_find_index_borrowed(&gallery.*.semantics, + component_state_gallery_heading_id(gallery.*.id, 2)); + let button_first: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, + component_state_gallery_button_caption_id(gallery.*.id, 0)); + let button_last: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, + component_state_gallery_button_id(gallery.*.id, 7)); + let heading3: i32 = semantics_find_index_borrowed(&gallery.*.semantics, + component_state_gallery_heading_id(gallery.*.id, 3)); + let navigation_first: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, + component_state_gallery_navigation_caption_id(gallery.*.id, 0)); + let navigation_last: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, + component_state_gallery_navigation_id(gallery.*.id, 5)); + let heading4: i32 = semantics_find_index_borrowed(&gallery.*.semantics, + component_state_gallery_heading_id(gallery.*.id, 4)); + let segmented: i32 = semantics_find_index_borrowed(&gallery.*.semantics, + component_state_gallery_segmented_id(gallery.*.id)); + expect(state, title >= 0 && title < heading0 && + heading0 < tier_first && tier_first < tier_last && + tier_last < heading1 && heading1 < surface_first && + surface_first < surface_last && surface_last < heading2 && + heading2 < button_first && button_first < button_last && + button_last < heading3 && heading3 < navigation_first && + navigation_first < navigation_last && navigation_last < heading4 && + heading4 < segmented, + "semantic traversal interleaves each heading with its visual fixture section"); +} + +fn gallery_test_surface_contract(state: *TestState, + gallery: *ComponentStateGalleryArtifact) void { + let tier_truth: i32 = 1; + let index: i32 = 0; + while (index < 4) { + let surface: SurfaceArtifact = gallery.*.tier_surfaces.data[index]; + let semantic_index: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, surface.id); + if (surface.mode != SurfaceMode.group || + surface.tier != component_state_gallery_tier(index) || + surface.style.material_token != surface_material_token(surface.tier) || + hit_tree_find_index_borrowed(&gallery.*.hits, surface.id) >= 0 || + semantic_index < 0 || + gallery.*.semantics.nodes.data[semantic_index].role != + SemanticRole.group || + gallery.*.semantics.nodes.data[semantic_index].action_mask != 0) { + tier_truth = 0; + } + index = index + 1; + } + expect(state, tier_truth != 0 && + gallery.*.tier_surfaces.data[0].style.elevation_token == + SemanticElevationToken.base && + gallery.*.tier_surfaces.data[1].style.elevation_token == + SemanticElevationToken.panel && + gallery.*.tier_surfaces.data[2].style.elevation_token == + SemanticElevationToken.raised && + gallery.*.tier_surfaces.data[3].style.elevation_token == + SemanticElevationToken.overlay, + "base panel raised and overlay are real read-only Surface tiers with no fake hits or actions"); + + let state_truth: i32 = 1; + index = 0; + while (index < 8) { + let surface: SurfaceArtifact = gallery.*.state_surfaces.data[index]; + let hit_index: i32 = hit_tree_find_index_borrowed( + &gallery.*.hits, surface.id); + let semantic_index: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, surface.id); + let expected_enabled: i32 = (index != 5 && index != 6) as i32; + if (surface.mode != SurfaceMode.actionable || + surface.tier != SurfaceTier.panel || + button_interaction_equal(surface.interaction, + surface_canonical_state( + component_state_gallery_surface_state(index))) == 0 || + surface.effectively_enabled != expected_enabled || + hit_index < 0 || semantic_index < 0 || + gallery.*.hits.nodes.data[hit_index].enabled != expected_enabled || + gallery.*.hits.nodes.data[hit_index].focusable != + expected_enabled || + ((gallery.*.semantics.nodes.data[semantic_index].action_mask != 0) + as i32) != expected_enabled) { + state_truth = 0; + } + index = index + 1; + } + expect(state, state_truth != 0 && + gallery.*.state_surfaces.data[1].hover_treatment_visible == 1 && + gallery.*.state_surfaces.data[2].focus_ring_visible == 1 && + gallery.*.state_surfaces.data[3].pressed_treatment_visible == 1 && + gallery.*.state_surfaces.data[4].selected_marker_visible == 1 && + gallery.*.state_surfaces.data[5].disabled_treatment_visible == 1 && + gallery.*.state_surfaces.data[6].loading_treatment_visible == 1 && + gallery.*.state_surfaces.data[7].error_treatment_visible == 1, + "the same actionable panel Surface exposes all eight canonical non-color state treatments"); +} + +fn gallery_test_control_state_contract(state: *TestState, + gallery: *ComponentStateGalleryArtifact) void { + let button_truth: i32 = 1; + let index: i32 = 0; + while (index < 8) { + let button: ButtonArtifact = gallery.*.buttons.data[index]; + let hit_index: i32 = hit_tree_find_index_borrowed( + &gallery.*.hits, button.id); + let semantic_index: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, button.id); + let expected_enabled: i32 = (index != 5 && index != 6) as i32; + if (button_interaction_equal(gallery.*.buttons.data[index].interaction, + button_canonical_state( + component_state_gallery_button_state(index))) == 0 || + button.effectively_enabled != expected_enabled || + hit_index < 0 || semantic_index < 0 || + gallery.*.hits.nodes.data[hit_index].enabled != expected_enabled || + gallery.*.hits.nodes.data[hit_index].focusable != + expected_enabled || + ((gallery.*.semantics.nodes.data[semantic_index].action_mask != 0) + as i32) != expected_enabled) { + button_truth = 0; + } + index = index + 1; + } + expect(state, button_truth != 0 && + gallery.*.buttons.data[1].style.fill_token == + SemanticColorToken.surface_selected && + gallery.*.buttons.data[2].focus_ring_visible == 1 && + gallery.*.buttons.data[3].style.elevation == + SemanticElevationToken.base && + gallery.*.buttons.data[4].interaction.selected == 1 && + gallery.*.buttons.data[4].style.border_token == + SemanticColorToken.accent && + gallery.*.buttons.data[5].effectively_enabled == 0 && + gallery.*.buttons.data[5].style.text_token == + SemanticColorToken.text_disabled && + gallery.*.buttons.data[6].content_visible == 0 && + gallery.*.buttons.data[7].style.border_token == + SemanticColorToken.status_error, + "one secondary Button exposes rest hover focus pressed selected disabled loading and error"); + expect(state, gallery_test_owner_has_path(&gallery.*.display, + component_state_gallery_button_id(gallery.*.id, 0)) != 0 && + gallery_test_owner_has_path(&gallery.*.display, + component_state_gallery_button_id(gallery.*.id, 6)) == 0 && + gallery_test_owner_has_path(&gallery.*.display, + component_state_gallery_button_caption_id( + gallery.*.id, 6)) != 0, + "loading uses the real Button progress substitution while its visible state caption remains text"); + + let navigation_truth: i32 = 1; + index = 0; + while (index < 6) { + let navigation: NavigationItemArtifact = + gallery.*.navigation_items.data[index]; + let hit_index: i32 = hit_tree_find_index_borrowed( + &gallery.*.hits, navigation.id); + let semantic_index: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, navigation.id); + let expected_enabled: i32 = (index != 5) as i32; + if (button_interaction_equal( + gallery.*.navigation_items.data[index].interaction, + navigation_item_canonical_state( + component_state_gallery_navigation_state(index))) == 0 || + navigation.effectively_enabled != expected_enabled || + hit_index < 0 || semantic_index < 0 || + gallery.*.hits.nodes.data[hit_index].enabled != expected_enabled || + gallery.*.hits.nodes.data[hit_index].focusable != + expected_enabled || + ((gallery.*.semantics.nodes.data[semantic_index].action_mask != 0) + as i32) != expected_enabled) { + navigation_truth = 0; + } + index = index + 1; + } + expect(state, navigation_truth != 0 && + gallery.*.navigation_items.data[1].style.fill_token == + SemanticColorToken.surface_interactive && + gallery.*.navigation_items.data[2].focus_ring_visible == 1 && + gallery.*.navigation_items.data[3].style.fill_token == + SemanticColorToken.accent_emphasis && + gallery.*.navigation_items.data[4].selected_marker_visible == 1 && + gallery.*.navigation_items.data[4].style.elevation == + SemanticElevationToken.panel && + gallery.*.navigation_items.data[5].label_color_token == + SemanticColorToken.text_disabled, + "one NavigationItem exposes the required six canonical tab states"); +} + +fn gallery_test_focus_and_segment_contract( + state: *TestState, + gallery: *ComponentStateGalleryArtifact) void { + let surface_focus: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, + component_state_gallery_surface_id(gallery.*.id, 2)); + let button_focus: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, + component_state_gallery_button_id(gallery.*.id, 2)); + let navigation_focus: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, + component_state_gallery_navigation_id(gallery.*.id, 2)); + let live_focus: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, gallery.*.live_focus_id); + expect(state, surface_focus >= 0 && button_focus >= 0 && + navigation_focus >= 0 && live_focus >= 0 && + gallery.*.semantics.nodes.data[surface_focus].focused == 0 && + gallery.*.semantics.nodes.data[button_focus].focused == 0 && + gallery.*.semantics.nodes.data[navigation_focus].focused == 0 && + semantic_text_equal( + gallery.*.semantics.nodes.data[button_focus].value, + "Focus fixture") != 0 && + gallery.*.semantics.nodes.data[live_focus].focused == 1 && + component_state_gallery_focused_semantics_count( + &gallery.*.semantics) == 1, + "focus snapshots stay visibly labelled while one segmented option owns truthful live focus"); + + let model: SegmentedControlModel = gallery.*.runtime_state.segmented; + let segment_spec: SegmentedControlSpec = segmented_control_spec( + component_state_gallery_segmented_id(gallery.*.id), + semantic_root_key(), gallery.*.layout.segmented_bounds, + "Component state behavior", model); + segment_spec.density = gallery.*.density; + let next: SegmentedControlTransition = segmented_control_reduce(model, + segmented_control_event(SegmentedControlEventKind.key_next, + segmented_control_no_index())); + expect(state, gallery.*.segmented.selected_index == 0 && + gallery.*.segmented.roving_index == 1 && + gallery.*.segmented.has_focus == 1 && + gallery.*.segmented.focus_visible == 1 && + segmented_control_index_enabled(model, 2) == 0 && + next.rejected == 0 && next.activated == 1 && + next.model.selected_index == 3 && next.model.roving_index == 3, + "the live SegmentedControl shows selection focus-visible and disabled state and skips disabled input"); + let option_truth: i32 = 1; + let option_index: i32 = 0; + while (option_index < component_state_gallery_segment_count()) { + let option_id: NodeKey = component_state_gallery_segment_id( + gallery.*.id, option_index); + let option_bounds: Rect = segmented_control_segment_bounds( + segment_spec, option_index); + let hit_index: i32 = hit_tree_find_index_borrowed( + &gallery.*.hits, option_id); + let semantic_index: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, option_id); + let expected_enabled: i32 = (option_index != 2) as i32; + if (hit_index < 0 || semantic_index < 0 || + gallery_test_rect_equal( + gallery.*.hits.nodes.data[hit_index].bounds, + option_bounds) == 0 || + gallery_test_rect_equal( + gallery.*.semantics.nodes.data[semantic_index].bounds, + option_bounds) == 0 || + gallery.*.hits.nodes.data[hit_index].enabled != expected_enabled || + gallery.*.hits.nodes.data[hit_index].focusable != + expected_enabled || + ((gallery.*.semantics.nodes.data[semantic_index].action_mask != 0) + as i32) != expected_enabled || + gallery_test_owner_has_operation( + &gallery.*.display, option_id) == 0) { + option_truth = 0; + } + option_index = option_index + 1; + } + let group_hit: i32 = hit_tree_find_index_borrowed(&gallery.*.hits, + component_state_gallery_segmented_id(gallery.*.id)); + let group_semantic: i32 = semantics_find_index_borrowed( + &gallery.*.semantics, + component_state_gallery_segmented_id(gallery.*.id)); + expect(state, option_truth != 0 && group_hit >= 0 && + group_semantic >= 0 && gallery_test_rect_equal( + gallery.*.hits.nodes.data[group_hit].bounds, + gallery.*.layout.segmented_bounds) != 0 && + gallery_test_rect_equal( + gallery.*.semantics.nodes.data[group_semantic].bounds, + gallery.*.layout.segmented_bounds) != 0 && + gallery_test_owner_has_operation(&gallery.*.display, + component_state_gallery_segmented_id(gallery.*.id)) != 0, + "segmented group and option IDs share exact display hit and semantic geometry"); + let disabled_bounds: Rect = segmented_control_segment_bounds( + segment_spec, 2); + let disabled_hit: HitResult = hit_test(gallery.*.hits, + disabled_bounds.x + disabled_bounds.width / 2, + disabled_bounds.y + disabled_bounds.height / 2); + expect(state, disabled_hit.found == 0, + "disabled segmented geometry stays discoverable semantically but never enters pointer routing"); +} + +fn gallery_test_talkback_contract(state: *TestState, + gallery: *ComponentStateGalleryArtifact) void { + let session: TalkbackSession = talkback_session_make( + talkback_capabilities()); + let query_truth: i32 = 1; + let semantic_index: i32 = 0; + while (semantic_index < gallery.*.semantics.nodes.len) { + let semantic_id: NodeKey = + gallery.*.semantics.nodes.data[semantic_index].id; + let response: TalkbackResponse = talkback_dispatch(&session, + gallery.*.semantics, gallery.*.bounds, + talkback_request(1000 + semantic_index as i64, + TalkbackCommand.query, semantic_id, + gallery.*.semantics.revision)); + if (response.status != TalkbackStatus.accepted || + response.target_kind != TalkbackTargetKind.semantic_id || + node_key_equal(response.target_id, semantic_id) == 0 || + response.resolved_node_index != semantic_index) { + query_truth = 0; + } + semantic_index = semantic_index + 1; + } + expect(state, query_truth != 0, + "every published semantic node is queryable by its exact Talkback ID without pixels"); + let rest_surface: TalkbackResponse = talkback_dispatch(&session, + gallery.*.semantics, gallery.*.bounds, + talkback_request(1, TalkbackCommand.click, + component_state_gallery_surface_id(gallery.*.id, 0), + gallery.*.semantics.revision)); + let disabled_surface: TalkbackResponse = talkback_dispatch(&session, + gallery.*.semantics, gallery.*.bounds, + talkback_request(2, TalkbackCommand.click, + component_state_gallery_surface_id(gallery.*.id, 5), + gallery.*.semantics.revision)); + let tier_click: TalkbackResponse = talkback_dispatch(&session, + gallery.*.semantics, gallery.*.bounds, + talkback_request(3, TalkbackCommand.click, + component_state_gallery_tier_id(gallery.*.id, 2), + gallery.*.semantics.revision)); + let selected_navigation: TalkbackResponse = talkback_dispatch(&session, + gallery.*.semantics, gallery.*.bounds, + talkback_request(4, TalkbackCommand.query, + component_state_gallery_navigation_id(gallery.*.id, 4), + gallery.*.semantics.revision)); + let focused_segment: TalkbackResponse = talkback_dispatch(&session, + gallery.*.semantics, gallery.*.bounds, + talkback_request(5, TalkbackCommand.query, + component_state_gallery_segment_id(gallery.*.id, 1), + gallery.*.semantics.revision)); + let disabled_segment: TalkbackResponse = talkback_dispatch(&session, + gallery.*.semantics, gallery.*.bounds, + talkback_request(6, TalkbackCommand.click, + component_state_gallery_segment_id(gallery.*.id, 2), + gallery.*.semantics.revision)); + let rest_button: TalkbackResponse = talkback_dispatch(&session, + gallery.*.semantics, gallery.*.bounds, + talkback_request(7, TalkbackCommand.click, + component_state_gallery_button_id(gallery.*.id, 0), + gallery.*.semantics.revision)); + let disabled_button: TalkbackResponse = talkback_dispatch(&session, + gallery.*.semantics, gallery.*.bounds, + talkback_request(8, TalkbackCommand.click, + component_state_gallery_button_id(gallery.*.id, 5), + gallery.*.semantics.revision)); + let loading_button: TalkbackResponse = talkback_dispatch(&session, + gallery.*.semantics, gallery.*.bounds, + talkback_request(9, TalkbackCommand.click, + component_state_gallery_button_id(gallery.*.id, 6), + gallery.*.semantics.revision)); + let disabled_navigation: TalkbackResponse = talkback_dispatch(&session, + gallery.*.semantics, gallery.*.bounds, + talkback_request(10, TalkbackCommand.click, + component_state_gallery_navigation_id(gallery.*.id, 5), + gallery.*.semantics.revision)); + expect(state, rest_surface.status == TalkbackStatus.accepted && + rest_surface.emitted_action == 1 && + disabled_surface.status == TalkbackStatus.action_unavailable && + tier_click.status == TalkbackStatus.action_unavailable && + rest_button.status == TalkbackStatus.accepted && + rest_button.emitted_action == 1 && + disabled_button.status == TalkbackStatus.action_unavailable && + loading_button.status == TalkbackStatus.action_unavailable && + disabled_navigation.status == TalkbackStatus.action_unavailable, + "Talkback emits explicit action requests while disabled loading and read-only fixtures fail closed"); + expect(state, selected_navigation.status == TalkbackStatus.accepted && + (selected_navigation.state_flags & + talkback_state_selected_bit()) != 0 && + focused_segment.status == TalkbackStatus.accepted && + (focused_segment.state_flags & talkback_state_focused_bit()) != 0 && + disabled_segment.status == TalkbackStatus.action_unavailable, + "Talkback exposes selected focused and disabled state by stable IDs without pixels"); + talkback_session_free(&session); +} + +fn gallery_test_runtime_contract(state: *TestState, + face: *OpenTypeFace) void { + let spec: ComponentStateGallerySpec = gallery_test_spec(); + let initial: ComponentStateGalleryRuntimeState = + component_state_gallery_runtime_state_default(); + let resting: ComponentStateGalleryArtifact = + component_state_gallery_build_with_state(spec, face, initial); + let surface_snapshot: i32 = semantics_find_index_borrowed( + &resting.semantics, + component_state_gallery_surface_id(spec.id, 2)); + let button_snapshot: i32 = semantics_find_index_borrowed( + &resting.semantics, + component_state_gallery_button_id(spec.id, 2)); + let navigation_snapshot: i32 = semantics_find_index_borrowed( + &resting.semantics, + component_state_gallery_navigation_id(spec.id, 2)); + expect(state, component_state_gallery_runtime_state_valid( + spec.id, initial) != 0 && + component_state_gallery_valid(&resting) != 0 && + resting.frozen_conformance == 0 && + resting.display.ops.len == 407 && + resting.segmented.has_focus == 0 && + resting.segmented.focus_visible == 0 && + node_key_equal(resting.live_focus_id, + semantic_none_key()) != 0 && + component_state_gallery_focused_semantics_count( + &resting.semantics) == 0, + "the native-default gallery starts with no fabricated focus and keeps the exact retained fixture shape"); + expect(state, surface_snapshot >= 0 && button_snapshot >= 0 && + navigation_snapshot >= 0 && + resting.semantics.nodes.data[surface_snapshot].focused == 0 && + resting.semantics.nodes.data[button_snapshot].focused == 0 && + resting.semantics.nodes.data[navigation_snapshot].focused == 0 && + resting.state_surfaces.data[2].focus_ring_visible == 1 && + resting.buttons.data[2].focus_ring_visible == 1 && + resting.navigation_items.data[2].focus_ring_visible == 1, + "visual focus specimens retain rings while their semantic focus stays false"); + + let button_id: NodeKey = component_state_gallery_button_id(spec.id, 0); + let focused_transition: ComponentStateGalleryTransition = + component_state_gallery_reduce(initial, spec.id, + component_state_gallery_event( + ComponentStateGalleryEventKind.focus_gained_keyboard, + button_id)); + let focused: ComponentStateGalleryArtifact = + component_state_gallery_build_with_state( + spec, face, focused_transition.state); + let focused_index: i32 = semantics_find_index_borrowed( + &focused.semantics, button_id); + expect(state, focused_transition.rejected == 0 && + focused_transition.state_changed == 1 && + focused_transition.focus_changed == 1 && + focused_transition.activation_changed == 0 && + focused_transition.state.revision == 1 && + component_state_gallery_valid(&focused) != 0 && + focused_index >= 0 && + focused.semantics.nodes.data[focused_index].focused == 1 && + focused.buttons.data[0].focus_ring_visible == 1 && + node_key_equal(focused.live_focus_id, button_id) != 0 && + component_state_gallery_focused_semantics_count( + &focused.semantics) == 1, + "one real enabled runtime target owns the only semantic focus and keyboard-visible ring"); + + let activated_button: ComponentStateGalleryTransition = + component_state_gallery_reduce(focused_transition.state, spec.id, + component_state_gallery_event( + ComponentStateGalleryEventKind.activate_target, + button_id)); + let surface_id: NodeKey = component_state_gallery_surface_id(spec.id, 0); + let activated_surface: ComponentStateGalleryTransition = + component_state_gallery_reduce(activated_button.state, spec.id, + component_state_gallery_event( + ComponentStateGalleryEventKind.activate_target, + surface_id)); + let navigation_id: NodeKey = component_state_gallery_navigation_id( + spec.id, 0); + let activated_navigation: ComponentStateGalleryTransition = + component_state_gallery_reduce(activated_surface.state, spec.id, + component_state_gallery_event( + ComponentStateGalleryEventKind.activate_target, + navigation_id)); + let outcome: ComponentStateGalleryArtifact = + component_state_gallery_build_with_state( + spec, face, activated_navigation.state); + expect(state, activated_button.activation_changed == 1 && + activated_surface.activation_changed == 1 && + activated_navigation.activation_changed == 1 && + activated_navigation.state.activation_count == 3 && + activated_navigation.state.revision == 4 && + node_key_equal(activated_navigation.state.last_activated, + navigation_id) != 0 && + component_state_gallery_valid(&outcome) != 0 && + outcome.runtime_hash == component_state_gallery_runtime_state_hash( + activated_navigation.state) && + outcome.evidence_hash != focused.evidence_hash, + "enabled Button Surface and NavigationItem actions increment an inspectable outcome and revision"); + + let rejected_disabled: ComponentStateGalleryTransition = + component_state_gallery_reduce(activated_navigation.state, spec.id, + component_state_gallery_event( + ComponentStateGalleryEventKind.activate_target, + component_state_gallery_button_id(spec.id, 5))); + let rejected_loading: ComponentStateGalleryTransition = + component_state_gallery_reduce(activated_navigation.state, spec.id, + component_state_gallery_event( + ComponentStateGalleryEventKind.activate_target, + component_state_gallery_button_id(spec.id, 6))); + let rejected_read_only: ComponentStateGalleryTransition = + component_state_gallery_reduce(activated_navigation.state, spec.id, + component_state_gallery_event( + ComponentStateGalleryEventKind.activate_target, + component_state_gallery_tier_id(spec.id, 0))); + expect(state, rejected_disabled.rejected == 1 && + rejected_loading.rejected == 1 && + rejected_read_only.rejected == 1 && + rejected_disabled.state_changed == 0 && + rejected_disabled.activation_changed == 0 && + rejected_disabled.state.activation_count == 3 && + rejected_disabled.state.revision == 4 && + component_state_gallery_runtime_state_hash( + rejected_disabled.state) == + component_state_gallery_runtime_state_hash( + activated_navigation.state), + "disabled loading and read-only fixtures reject activation without fabricating an outcome"); + + let segment_one: NodeKey = component_state_gallery_segment_id( + spec.id, 1); + let segment_focus: ComponentStateGalleryTransition = + component_state_gallery_reduce(initial, spec.id, + component_state_gallery_event( + ComponentStateGalleryEventKind.focus_gained_keyboard, + segment_one)); + let segment_next: ComponentStateGalleryTransition = + component_state_gallery_reduce(segment_focus.state, spec.id, + component_state_gallery_segmented_event( + SegmentedControlEventKind.key_next, + segmented_control_no_index())); + let segment_three: NodeKey = component_state_gallery_segment_id( + spec.id, 3); + let segmented: ComponentStateGalleryArtifact = + component_state_gallery_build_with_state( + spec, face, segment_next.state); + let segment_three_index: i32 = semantics_find_index_borrowed( + &segmented.semantics, segment_three); + let disabled_segment: ComponentStateGalleryTransition = + component_state_gallery_reduce(segment_next.state, spec.id, + component_state_gallery_event( + ComponentStateGalleryEventKind.activate_target, + component_state_gallery_segment_id(spec.id, 2))); + expect(state, segment_focus.rejected == 0 && + segment_next.rejected == 0 && segment_next.state_changed == 1 && + segment_next.focus_changed == 1 && + segment_next.selection_changed == 1 && + segment_next.activation_changed == 1 && + segment_next.state.segmented.selected_index == 3 && + segment_next.state.segmented.roving_index == 3 && + segment_next.state.activation_count == 1 && + node_key_equal(segment_next.state.focused, + segment_three) != 0 && + node_key_equal(segment_next.state.last_activated, + segment_three) != 0 && + component_state_gallery_valid(&segmented) != 0 && + segment_three_index >= 0 && + segmented.semantics.nodes.data[segment_three_index].focused == 1 && + segmented.semantics.nodes.data[segment_three_index].selected == 1 && + disabled_segment.rejected == 1 && + disabled_segment.activation_changed == 0, + "SegmentedControl keyboard reduction skips the disabled option and records the selected activation"); + + let invalid: ComponentStateGalleryRuntimeState = initial; + invalid.focus_visible = 1; + let invalid_gallery: ComponentStateGalleryArtifact = + component_state_gallery_build_with_state(spec, face, invalid); + expect(state, component_state_gallery_runtime_state_valid( + spec.id, invalid) == 0 && + invalid_gallery.error == + ComponentStateGalleryError.invalid_runtime_state && + invalid_gallery.display.ops.len == 0 && + invalid_gallery.hits.nodes.len == 0 && + invalid_gallery.semantics.nodes.len == 0, + "invalid runtime focus truth fails closed before retained publication"); + + component_state_gallery_free(&invalid_gallery); + component_state_gallery_free(&segmented); + component_state_gallery_free(&outcome); + component_state_gallery_free(&focused); + component_state_gallery_free(&resting); +} + +fn gallery_test_adaptive_contract(state: *TestState, + face: *OpenTypeFace, + baseline: *ComponentStateGalleryArtifact) void { + let rtl_spec: ComponentStateGallerySpec = gallery_test_spec(); + rtl_spec.density = FlexDensity.touch; + rtl_spec.direction = LayoutDirection.rtl; + let rtl: ComponentStateGalleryArtifact = + component_state_gallery_build_frozen_conformance(rtl_spec, face); + expect(state, component_state_gallery_valid(&rtl) != 0 && + rtl.layout.required_height == 1265 * unit_scale() && + rtl.layout.tier_placements.data[0].cell_bounds.x > + rtl.layout.tier_placements.data[3].cell_bounds.x && + baseline.*.layout.tier_placements.data[0].cell_bounds.x < + baseline.*.layout.tier_placements.data[3].cell_bounds.x && + node_key_equal(rtl.buttons.data[4].id, + baseline.*.buttons.data[4].id) != 0, + "touch density and RTL mirror gallery Flex rows without replacing stable IDs"); + component_state_gallery_free(&rtl); +} + +fn gallery_test_determinism_contract(state: *TestState, + face: *OpenTypeFace, + first: *ComponentStateGalleryArtifact) void { + let second: ComponentStateGalleryArtifact = + component_state_gallery_build_frozen_conformance( + gallery_test_spec(), face); + let first_raster: CpuRasterResult = cpu_rasterize(first.*.display, + 1440, 1280); + let second_raster: CpuRasterResult = cpu_rasterize(second.display, + 1440, 1280); + let first_hash: i64 = 0; + let second_hash: i64 = 0; + if (first_raster.error == CpuRasterError.none) { + first_hash = cpu_surface_hash(first_raster.surface); + } + if (second_raster.error == CpuRasterError.none) { + second_hash = cpu_surface_hash(second_raster.surface); + } + expect(state, component_state_gallery_valid(&second) != 0 && + first.*.evidence_hash == second.evidence_hash && + first.*.display_hash == second.display_hash && + first.*.hit_hash == second.hit_hash && + first.*.talkback_hash == second.talkback_hash, + "identical Flex state token and font inputs reproduce all retained evidence"); + expect(state, first_raster.error == CpuRasterError.none && + second_raster.error == CpuRasterError.none && first_hash != 0 && + first_hash == second_hash && first_raster.pixels_touched > 0, + "the deterministic CPU oracle produces identical nonempty component pixels"); + _zag_print("Component state gallery evidence hash: "); + _zag_print_i64(first.*.evidence_hash); _zag_println(""); + _zag_print("Component state gallery CPU hash: "); + _zag_print_i64(first_hash); _zag_println(""); + cpu_raster_free(&second_raster); + cpu_raster_free(&first_raster); + component_state_gallery_free(&second); +} + +fn gallery_test_atomic_merge_contract(state: *TestState) void { + let spec: ComponentStateGallerySpec = gallery_test_spec(); + let destination: ComponentStateGalleryArtifact = + component_state_gallery_empty(spec); + _ = component_state_gallery_root_semantics(&destination, spec); + let collision_id: NodeKey = component_state_gallery_button_id(spec.id, 0); + let existing_hit: HitNode = hit_node(collision_id, hit_root_key(), + rect(0, 0, 100 * unit_scale(), 48 * unit_scale())); + _ = hit_tree_add(&destination.hits, existing_hit); + let existing_semantic: SemanticsSpec = semantics_spec(collision_id, + spec.id, SemanticRole.button, "Existing collision"); + existing_semantic.has_bounds = 1; + existing_semantic.bounds = existing_hit.bounds; + _ = semantics_add(&destination.semantics, existing_semantic); + + let scratch_display: DisplayList = display_list_make(); + let scratch_hits: HitTree = hit_tree_make(); + let scratch_semantics: SemanticsTree = semantics_tree_make(); + let button: ButtonSpec = button_spec(collision_id, semantic_root_key(), + existing_hit.bounds, "Run build"); + let built: ButtonBuildResult = button_emit(button, &scratch_display, + &scratch_hits, &scratch_semantics); + let ops_before: i32 = destination.display.ops.len; + let display_hash: i64 = destination.display.content_hash; + let hits_before: i32 = destination.hits.nodes.len; + let hit_revision: i64 = destination.hits.revision; + let semantics_before: i32 = destination.semantics.nodes.len; + let semantic_revision: i64 = destination.semantics.revision; + let merged: ComponentStateGalleryMergeResult = + component_state_gallery_merge_scratch(&destination, + &scratch_display, &scratch_hits, &scratch_semantics, + 1, spec.id, semantic_none_key(), "Rest fixture", 1, 1); + expect(state, built.error == ButtonBuildError.none && + merged.error == ComponentStateGalleryMergeError.hit_error && + merged.hit_error == HitTreeError.duplicate_id && + destination.display.ops.len == ops_before && + destination.display.content_hash == display_hash && + destination.hits.nodes.len == hits_before && + destination.hits.revision == hit_revision && + destination.semantics.nodes.len == semantics_before && + destination.semantics.revision == semantic_revision, + "scratch hosting rolls display hit and semantics back atomically on stable-ID collision"); + + let late: ComponentStateGalleryArtifact = + component_state_gallery_empty(spec); + _ = component_state_gallery_root_semantics(&late, spec); + let late_existing: SemanticsSpec = semantics_spec(collision_id, + spec.id, SemanticRole.button, "Late semantic collision"); + late_existing.has_bounds = 1; + late_existing.bounds = existing_hit.bounds; + _ = semantics_add(&late.semantics, late_existing); + let late_ops: i32 = late.display.ops.len; + let late_display_hash: i64 = late.display.content_hash; + let late_hits: i32 = late.hits.nodes.len; + let late_hit_revision: i64 = late.hits.revision; + let late_semantics: i32 = late.semantics.nodes.len; + let late_semantic_revision: i64 = late.semantics.revision; + let late_merged: ComponentStateGalleryMergeResult = + component_state_gallery_merge_scratch(&late, + &scratch_display, &scratch_hits, &scratch_semantics, + 1, spec.id, semantic_none_key(), "Rest fixture", 1, 1); + expect(state, + late_merged.error == ComponentStateGalleryMergeError.semantics_error && + late_merged.semantics_error == SemanticsError.duplicate_id && + late.display.ops.len == late_ops && + late.display.content_hash == late_display_hash && + late.hits.nodes.len == late_hits && + late.hits.revision == late_hit_revision && + late.semantics.nodes.len == late_semantics && + late.semantics.revision == late_semantic_revision, + "late semantic collision rolls earlier display and hit contributions back atomically"); + component_state_gallery_free(&late); + semantics_tree_free(&scratch_semantics); + hit_tree_free(&scratch_hits); + display_list_free(&scratch_display); + component_state_gallery_free(&destination); +} + +fn gallery_test_segmented_text_atomic_contract( + state: *TestState, + face: *OpenTypeFace) void { + let spec: ComponentStateGallerySpec = gallery_test_spec(); + let destination: ComponentStateGalleryArtifact = + component_state_gallery_empty(spec); + display_list_free(&destination.display); + destination.display = display_list_make_with_resource_limits( + 2, 268435456, 33554432); + destination.layout.segmented_bounds = rect(360 * unit_scale(), + 40 * unit_scale(), 720 * unit_scale(), 50 * unit_scale()); + _ = component_state_gallery_root_semantics(&destination, spec); + let display_hash: i64 = destination.display.content_hash; + let hit_revision: i64 = destination.hits.revision; + let semantic_revision: i64 = destination.semantics.revision; + let emitted: i32 = component_state_gallery_emit_segmented( + &destination, face); + expect(state, emitted == 0 && + destination.error == + ComponentStateGalleryError.text_contribution_error && + destination.display.ops.len == 0 && + destination.display.resources.resources.len == 0 && + destination.display.content_hash == display_hash && + destination.hits.nodes.len == 0 && + destination.hits.revision == hit_revision && + destination.semantics.nodes.len == 1 && + destination.semantics.revision == semantic_revision && + destination.text_artifact_count == 0 && + destination.text_semantic_count == 0 && + node_key_equal(destination.live_focus_id, + semantic_none_key()) != 0, + "segmented label resource failure rolls component chrome prior labels hits and semantics back"); + component_state_gallery_free(&destination); +} + +fn gallery_test_failure_contract(state: *TestState, + face: *OpenTypeFace) void { + let invalid_face: OpenTypeFace = opentype_face_empty(); + let missing: ComponentStateGalleryArtifact = component_state_gallery_build( + gallery_test_spec(), &invalid_face); + expect(state, missing.error == ComponentStateGalleryError.missing_font && + missing.display.ops.len == 0 && missing.hits.nodes.len == 0 && + missing.semantics.nodes.len == 0 && missing.evidence_hash == 0, + "a missing font fails before layout publication or partial retained output"); + + let narrow_spec: ComponentStateGallerySpec = gallery_test_spec(); + narrow_spec.bounds.width = + component_state_gallery_minimum_width(narrow_spec.density) - 1; + let narrow: ComponentStateGalleryArtifact = + component_state_gallery_build(narrow_spec, face); + let short_spec: ComponentStateGallerySpec = gallery_test_spec(); + short_spec.bounds.height = + component_state_gallery_required_height(short_spec.density) - 1; + let short: ComponentStateGalleryArtifact = + component_state_gallery_build(short_spec, face); + expect(state, narrow.error == ComponentStateGalleryError.layout_overflow && + short.error == ComponentStateGalleryError.layout_overflow && + narrow.display.ops.len == 0 && short.display.ops.len == 0 && + narrow.semantics.nodes.len == 0 && short.semantics.nodes.len == 0, + "one-unit width and height deficits fail closed before any component emission"); + + let invalid_spec: ComponentStateGallerySpec = gallery_test_spec(); + invalid_spec.id = node_key(0); + let invalid: ComponentStateGalleryArtifact = + component_state_gallery_build(invalid_spec, face); + expect(state, invalid.error == ComponentStateGalleryError.invalid_spec && + invalid.display.ops.len == 0 && invalid.semantics.nodes.len == 0, + "invalid root identity fails before font layout or retained mutation"); + + component_state_gallery_free(&invalid); + component_state_gallery_free(&short); + component_state_gallery_free(&narrow); + component_state_gallery_free(&missing); + opentype_face_free(&invalid_face); +} + +fn main() i32 { + if (_zag_argc() != 2) { + _zag_eprintln("usage: component_state_gallery_contract "); + return 2; + } + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + let face: OpenTypeFace = gallery_test_face(_zag_arg(1)); + expect(&state, face.error == OpenTypeError.none, + "fixture font parses through the Zag-owned OpenType reader"); + if (face.error != OpenTypeError.none) { + opentype_face_free(&face); + return gallery_test_finish(&state); + } + let gallery: ComponentStateGalleryArtifact = + component_state_gallery_build_frozen_conformance( + gallery_test_spec(), &face); + expect(&state, component_state_gallery_valid(&gallery) != 0, + "the complete gallery builds before fixed fixture slots are inspected"); + if (component_state_gallery_valid(&gallery) == 0) { + component_state_gallery_free(&gallery); + opentype_face_free(&face); + return gallery_test_finish(&state); + } + gallery_test_structure_contract(&state, &gallery); + gallery_test_geometry_contract(&state, &gallery); + gallery_test_semantic_reading_order(&state, &gallery); + gallery_test_surface_contract(&state, &gallery); + gallery_test_control_state_contract(&state, &gallery); + gallery_test_focus_and_segment_contract(&state, &gallery); + gallery_test_talkback_contract(&state, &gallery); + gallery_test_runtime_contract(&state, &face); + gallery_test_adaptive_contract(&state, &face, &gallery); + gallery_test_determinism_contract(&state, &face, &gallery); + gallery_test_atomic_merge_contract(&state); + gallery_test_segmented_text_atomic_contract(&state, &face); + gallery_test_failure_contract(&state, &face); + component_state_gallery_free(&gallery); + opentype_face_free(&face); + return gallery_test_finish(&state); +} diff --git a/tests/design_tokens_contract.zag b/tests/design_tokens_contract.zag index 4376067..d997ba6 100644 --- a/tests/design_tokens_contract.zag +++ b/tests/design_tokens_contract.zag @@ -53,33 +53,42 @@ fn main() i32 { let panel: ResolvedElevationToken = semantic_elevation(SemanticElevationToken.panel); let raised: ResolvedElevationToken = semantic_elevation(SemanticElevationToken.raised); let overlay: ResolvedElevationToken = semantic_elevation(SemanticElevationToken.overlay); - expect(&state, base.shadow_alpha < panel.shadow_alpha && - panel.shadow_alpha < raised.shadow_alpha && - raised.shadow_alpha < overlay.shadow_alpha, - "base panel raised and overlay elevations have ordered depth"); - let shell_material: ResolvedMaterialToken = semantic_material( - SemanticMaterialToken.shell); - let raised_material: ResolvedMaterialToken = semantic_material( - SemanticMaterialToken.raised); - let overlay_material: ResolvedMaterialToken = semantic_material( - SemanticMaterialToken.overlay); - expect(&state, semantic_material_token_count() == 5 && - bytes_equal(shell_material.id, "material.glass.shell") != 0 && - shell_material.elevation_token == SemanticElevationToken.base && - raised_material.elevation_token == SemanticElevationToken.raised && - overlay_material.elevation_token == SemanticElevationToken.overlay && - shell_material.fill_token != raised_material.fill_token, - "glass materials bind inspectable fill radius depth and fallback rules"); - let backdrop_first: Paint = semantic_material_band_paint( - SemanticMaterialToken.showcase_backdrop, 0, 48); - let backdrop_last: Paint = semantic_material_band_paint( - SemanticMaterialToken.showcase_backdrop, 47, 48); - expect(&state, paint_equal(backdrop_first, backdrop_last) == 0 && - backdrop_first.alpha == backdrop_last.alpha && + expect(&state, base.ambient_shadow_alpha < panel.ambient_shadow_alpha && + panel.ambient_shadow_alpha < raised.ambient_shadow_alpha && + raised.ambient_shadow_alpha < overlay.ambient_shadow_alpha, + "base panel raised and overlay elevations have ordered ambient depth"); + expect(&state, panel.contact_shadow_alpha > 0 && + panel.contact_shadow_alpha < raised.contact_shadow_alpha && + raised.contact_shadow_alpha < overlay.contact_shadow_alpha, + "contact shadow alpha increases with elevation tier"); + expect(&state, base.edge_highlight_alpha < panel.edge_highlight_alpha && + panel.edge_highlight_alpha < raised.edge_highlight_alpha && + raised.edge_highlight_alpha < overlay.edge_highlight_alpha, + "edge highlight alpha increases with elevation tier"); + let solid_material: ResolvedMaterialToken = semantic_material( + SemanticMaterialToken.solid); + let veil_material: ResolvedMaterialToken = semantic_material( + SemanticMaterialToken.veil); + let glass_material: ResolvedMaterialToken = semantic_material( + SemanticMaterialToken.glass); + expect(&state, semantic_material_token_count() == 3 && + bytes_equal(solid_material.id, "material.solid") != 0 && + solid_material.elevation_token == SemanticElevationToken.base && + veil_material.elevation_token == SemanticElevationToken.panel && + glass_material.elevation_token == SemanticElevationToken.raised && + solid_material.fill_token != veil_material.fill_token && + veil_material.fill_token != glass_material.fill_token, + "three material tiers bind inspectable fill radius depth and fallback rules"); + let veil_first: Paint = semantic_material_band_paint( + SemanticMaterialToken.veil, 0, 48); + let veil_last: Paint = semantic_material_band_paint( + SemanticMaterialToken.veil, 47, 48); + expect(&state, paint_equal(veil_first, veil_last) == 0 && + veil_first.alpha == veil_last.alpha && bytes_equal(semantic_material( - SemanticMaterialToken.showcase_backdrop).id, - "material.showcase.backdrop") != 0, - "backdrop bands vary through one named material instead of local colors"); + SemanticMaterialToken.veil).id, + "material.veil") != 0, + "veil bands vary through one named material instead of local colors"); expect(&state, bytes_equal(showcase_category_description( ShowcaseCategory.frame_pacing), showcase_category_description( ShowcaseCategory.semantics)) == 0 && diff --git a/tests/linux_preview_composition_contract.zag b/tests/linux_preview_composition_contract.zag new file mode 100644 index 0000000..062d291 --- /dev/null +++ b/tests/linux_preview_composition_contract.zag @@ -0,0 +1,1149 @@ +@import("../src/showcase/linux_preview_composition.zag") +@import("../src/render/cpu_raster.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, + condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn composition_rect_equal(left: Rect, right: Rect) i32 { + return (left.x == right.x && left.y == right.y && + left.width == right.width && left.height == right.height) as i32; +} + +fn composition_display_has_exact_bounds(display: DisplayList, + id: NodeKey, bounds: Rect) i32 { + let index: i32 = 0; + while (index < display.ops.len) { + let op: DisplayOp = display.ops.data[index]; + if (node_key_equal(op.owner, id) != 0 && + composition_rect_equal(op.bounds, bounds) != 0) { return 1; } + index = index + 1; + } + return 0; +} + +fn composition_draw_path_count(display: DisplayList, id: NodeKey) i32 { + let count: i32 = 0; + let index: i32 = 0; + while (index < display.ops.len) { + let op: DisplayOp = display.ops.data[index]; + if (op.kind == DisplayOpKind.draw_path && + node_key_equal(op.owner, id) != 0) { count = count + 1; } + index = index + 1; + } + return count; +} + +fn composition_paint_equal(left: Paint, right: Paint) i32 { + return (left.red == right.red && left.green == right.green && + left.blue == right.blue && left.alpha == right.alpha && + left.stroke_width == right.stroke_width) as i32; +} + +fn composition_display_owner_has_token(display: DisplayList, + id: NodeKey, token: SemanticColorToken) i32 { + let expected: Paint = semantic_color_paint(token); + let index: i32 = 0; + while (index < display.ops.len) { + let op: DisplayOp = display.ops.data[index]; + if (node_key_equal(op.owner, id) != 0 && + composition_paint_equal(op.paint, expected) != 0) { return 1; } + index = index + 1; + } + return 0; +} + +fn composition_display_owner_has_token_alpha(display: DisplayList, + id: NodeKey, token: SemanticColorToken, alpha: i64) i32 { + let expected: Paint = semantic_color_with_alpha(token, alpha); + let index: i32 = 0; + while (index < display.ops.len) { + let op: DisplayOp = display.ops.data[index]; + if (node_key_equal(op.owner, id) != 0 && + composition_paint_equal(op.paint, expected) != 0) { return 1; } + index = index + 1; + } + return 0; +} + +fn composition_display_owner_has_token_bounds(display: DisplayList, + id: NodeKey, token: SemanticColorToken, + width: i64, height: i64) i32 { + let expected: Paint = semantic_color_paint(token); + let index: i32 = 0; + while (index < display.ops.len) { + let op: DisplayOp = display.ops.data[index]; + if (node_key_equal(op.owner, id) != 0 && + composition_paint_equal(op.paint, expected) != 0 && + op.bounds.width == width && op.bounds.height == height) { + return 1; + } + index = index + 1; + } + return 0; +} + +fn composition_semantic_hit_bounds_match( + artifact: LinuxPreviewCompositionArtifact, + id: NodeKey, expected: Rect) i32 { + let semantic_index: i32 = semantics_find_index(artifact.semantics, id); + let hit_index: i32 = hit_tree_find_index(artifact.hits, id); + if (semantic_index < 0 || hit_index < 0) { return 0; } + let semantic: SemanticsNode = artifact.semantics.nodes.data[semantic_index]; + let hit: HitNode = artifact.hits.nodes.data[hit_index]; + return (semantic.has_bounds == 1 && + composition_rect_equal(semantic.bounds, expected) != 0 && + composition_rect_equal(hit.bounds, expected) != 0 && + composition_display_has_exact_bounds(artifact.display, + id, expected) != 0) as i32; +} + +fn composition_chart_text_visual( + artifact: LinuxPreviewCompositionArtifact, id: NodeKey) i32 { + return (semantics_find_index(artifact.semantics, id) >= 0 && + composition_draw_path_count(artifact.display, id) == 1) as i32; +} + +fn composition_all_chart_text_visuals( + artifact: LinuxPreviewCompositionArtifact) i32 { + let chart: NodeKey = linux_preview_composition_chart_id(); + if (composition_chart_text_visual(artifact, + performance_chart_title_id(chart)) == 0 || + composition_chart_text_visual(artifact, + performance_chart_x_axis_id(chart)) == 0 || + composition_chart_text_visual(artifact, + performance_chart_x_axis_unit_id(chart)) == 0 || + composition_chart_text_visual(artifact, + performance_chart_y_axis_id(chart)) == 0 || + composition_chart_text_visual(artifact, + performance_chart_y_axis_unit_id(chart)) == 0 || + composition_chart_text_visual(artifact, + performance_chart_baseline_id(chart)) == 0 || + composition_chart_text_visual(artifact, + performance_chart_deadline_id(chart)) == 0) { return 0; } + let index: i32 = 0; + while (index < 7) { + if (composition_chart_text_visual(artifact, + performance_chart_x_tick_id(chart, index)) == 0 || + composition_chart_text_visual(artifact, + performance_chart_y_tick_id(chart, index)) == 0) { return 0; } + index = index + 1; + } + index = 0; + while (index < 3) { + if (composition_chart_text_visual(artifact, + linux_preview_composition_chart_series_id(index)) == 0) { + return 0; + } + index = index + 1; + } + return 1; +} + +fn composition_backdrop_contract(state: *TestState, + artifact: LinuxPreviewCompositionArtifact) void { + let contiguous: i32 = 1; + let next_y: i64 = 0; + let index: i32 = 0; + while (index < 48) { + if (index >= artifact.display.ops.len) { + contiguous = 0; + } else { + let op: DisplayOp = artifact.display.ops.data[index]; + if (op.kind != DisplayOpKind.fill_rect || + node_key_equal(op.owner, + linux_preview_composition_root_id()) == 0 || + op.bounds.x != 0 || op.bounds.y != next_y || + op.bounds.width != artifact.layout.viewport.width || + op.bounds.height <= 0) { + contiguous = 0; + } + next_y = next_y + op.bounds.height; + } + index = index + 1; + } + let samples_differ: i32 = 0; + if (artifact.display.ops.len >= 48) { + samples_differ = (composition_paint_equal( + artifact.display.ops.data[0].paint, + artifact.display.ops.data[47].paint) == 0) as i32; + } + expect(state, contiguous != 0 && + next_y == artifact.layout.viewport.height && samples_differ != 0, + "the tokenized CPU backdrop uses 48 contiguous ambient samples without four-step banding"); +} + +fn composition_navigation_contract(state: *TestState, + artifact: LinuxPreviewCompositionArtifact) void { + let index: i32 = 0; + let all_exact: i32 = 1; + while (index < linux_preview_navigation_count()) { + let id: NodeKey = linux_preview_composition_nav_id(index); + let expected: Rect = linux_preview_navigation_item_bounds( + artifact.layout, index, linux_preview_navigation_count()); + if (composition_semantic_hit_bounds_match( + artifact, id, expected) == 0) { all_exact = 0; } + index = index + 1; + } + expect(state, all_exact != 0 && + artifact.navigation_items_composed == 6, + "six NavigationItems share one Flex rectangle across display semantics and hits"); + + let overview_index: i32 = semantics_find_index(artifact.semantics, + linux_preview_composition_nav_id(0)); + let components_index: i32 = semantics_find_index(artifact.semantics, + linux_preview_composition_nav_id(1)); + let overview_hit: i32 = hit_tree_find_index(artifact.hits, + linux_preview_composition_nav_id(0)); + let components_hit: i32 = hit_tree_find_index(artifact.hits, + linux_preview_composition_nav_id(1)); + let navigation_index: i32 = semantics_find_index( + artifact.semantics, linux_preview_composition_navigation_id()); + expect(state, navigation_index >= 0 && + artifact.semantics.nodes.data[navigation_index].role == + SemanticRole.tab_list && + overview_index >= 0 && components_index >= 0 && + overview_hit >= 0 && components_hit >= 0 && + artifact.semantics.nodes.data[overview_index].role == SemanticRole.tab && + artifact.semantics.nodes.data[overview_index].selected == 1 && + artifact.semantics.nodes.data[overview_index].disabled == 0 && + artifact.hits.nodes.data[overview_hit].enabled == 1 && + artifact.semantics.nodes.data[components_index].selected == 0 && + artifact.semantics.nodes.data[components_index].disabled == 0 && + artifact.semantics.nodes.data[components_index].action_mask != 0 && + artifact.hits.nodes.data[components_hit].enabled == 1, + "navigation state truth enables the hosted Overview and Components routes"); +} + +fn composition_surface_contract(state: *TestState, + artifact: LinuxPreviewCompositionArtifact) void { + let expected_count: i32 = 3 + artifact.layout.status_visible + + (artifact.layout.chart_visible == 0) as i32; + let base_exact: i32 = composition_semantic_hit_bounds_match(artifact, + linux_preview_composition_header_id(), artifact.layout.header); + let panel_exact: i32 = composition_semantic_hit_bounds_match(artifact, + linux_preview_composition_navigation_surface_id(), + artifact.layout.navigation); + let raised_exact: i32 = composition_semantic_hit_bounds_match(artifact, + linux_preview_composition_main_id(), artifact.layout.main); + expect(state, artifact.surfaces_composed == expected_count && + base_exact != 0 && panel_exact != 0 && raised_exact != 0 && + composition_display_owner_has_token(artifact.display, + linux_preview_composition_header_id(), + SemanticColorToken.surface_base) != 0 && + composition_display_owner_has_token(artifact.display, + linux_preview_composition_navigation_surface_id(), + SemanticColorToken.surface_panel) != 0 && + composition_display_owner_has_token(artifact.display, + linux_preview_composition_main_id(), + SemanticColorToken.surface_raised) != 0, + "one canonical Surface proves base panel and raised hierarchy across pixels semantics and hits"); +} + +fn composition_controls_contract(state: *TestState, + artifact: LinuxPreviewCompositionArtifact) void { + let inspect_bounds: Rect = linux_preview_inspect_bounds(artifact.layout); + let inspect_index: i32 = semantics_find_index(artifact.semantics, + linux_preview_composition_inspect_id()); + expect(state, artifact.inspect_composed == 1 && inspect_index >= 0 && + artifact.semantics.nodes.data[inspect_index].role == SemanticRole.button && + composition_semantic_hit_bounds_match(artifact, + linux_preview_composition_inspect_id(), inspect_bounds) != 0, + "Inspect is a real Button with one shared stable ID and 44-pixel target"); + + if (artifact.layout.segmented_control_visible != 0) { + let spec: SegmentedControlSpec = + linux_preview_composition_segmented_spec(artifact.layout); + let all_exact: i32 = 1; + let index: i32 = 0; + while (index < 4) { + let id: NodeKey = linux_preview_composition_segment_id(index); + let expected: Rect = segmented_control_segment_bounds(spec, index); + if (composition_semantic_hit_bounds_match( + artifact, id, expected) == 0) { all_exact = 0; } + index = index + 1; + } + let state_index: i32 = semantics_find_index(artifact.semantics, + linux_preview_composition_segment_id(0)); + let render_index: i32 = semantics_find_index(artifact.semantics, + linux_preview_composition_segment_id(3)); + let group_index: i32 = semantics_find_index(artifact.semantics, + linux_preview_composition_segmented_id()); + let state_style: SegmentedControlResolvedStyle = + segmented_control_style(spec, 0); + let layout_style: SegmentedControlResolvedStyle = + segmented_control_style(spec, 1); + let motion_style: SegmentedControlResolvedStyle = + segmented_control_style(spec, 2); + let render_style: SegmentedControlResolvedStyle = + segmented_control_style(spec, 3); + expect(state, all_exact != 0 && group_index >= 0 && + artifact.semantics.nodes.data[group_index].role == + SemanticRole.tab_list && + artifact.segmented_control_composed == 1 && + artifact.segmented_items_composed == 4 && + state_index >= 0 && render_index >= 0 && + artifact.semantics.nodes.data[state_index].role == SemanticRole.tab && + artifact.semantics.nodes.data[state_index].selected == 1 && + artifact.semantics.nodes.data[render_index].disabled == 1 && + artifact.semantics.nodes.data[render_index].action_mask == 0 && + artifact.semantics.nodes.data[state_index].focused == 0 && + state_style.selected == 1 && layout_style.focused == 0 && + motion_style.hovered == 0 && render_style.disabled == 1, + "resting SegmentedControl shares exact geometry and never invents hover or focus state"); + } else { + expect(state, artifact.segmented_control_composed == 0 && + artifact.segmented_items_composed == 0 && + semantics_find_index(artifact.semantics, + linux_preview_composition_segment_id(0)) < 0, + "compact layout omits the selector instead of rendering unusable controls"); + } +} + +fn composition_chart_contract(state: *TestState, + artifact: LinuxPreviewCompositionArtifact) void { + if (artifact.layout.chart_visible != 0) { + let chart_index: i32 = semantics_find_index(artifact.semantics, + linux_preview_composition_chart_id()); + let table_index: i32 = semantics_find_index(artifact.semantics, + performance_chart_table_id( + linux_preview_composition_chart_id())); + expect(state, artifact.chart_composed == 1 && + artifact.chart_text_slots_composed == 24 && + chart_index >= 0 && table_index >= 0 && + artifact.semantics.nodes.data[table_index].role == SemanticRole.table && + artifact.semantics.nodes.data[table_index].row_count == 5 && + artifact.semantics.nodes.data[table_index].column_count == 4 && + composition_semantic_hit_bounds_match(artifact, + linux_preview_composition_chart_id(), + artifact.layout.chart) != 0, + "hosted chart shares exact layout bounds and exposes its equivalent semantic table"); + expect(state, composition_all_chart_text_visuals(artifact) != 0, + "all 24 chart text slots render through Text under their existing semantic IDs"); + expect(state, artifact.compact_summary_composed == 0 && + semantics_find_index(artifact.semantics, + linux_preview_composition_summary_id()) < 0, + "chart-visible layouts do not expose a contradictory compact summary"); + } else { + let summary_index: i32 = semantics_find_index(artifact.semantics, + linux_preview_composition_summary_id()); + expect(state, artifact.chart_composed == 0 && + artifact.chart_text_slots_composed == 0 && + artifact.compact_summary_composed == 1 && + summary_index >= 0 && + artifact.semantics.nodes.data[summary_index].role == SemanticRole.text && + composition_rect_equal( + artifact.semantics.nodes.data[summary_index].bounds, + artifact.layout.compact_summary) != 0 && + composition_display_has_exact_bounds(artifact.display, + linux_preview_composition_summary_surface_id(), + artifact.layout.compact_summary) != 0, + "compact layout replaces the chart with an honest semantic and visual summary"); + expect(state, semantics_find_index(artifact.semantics, + linux_preview_composition_chart_id()) < 0, + "compact composition does not publish an invisible chart target"); + } +} + +fn composition_status_contract(state: *TestState, + artifact: LinuxPreviewCompositionArtifact) void { + if (artifact.layout.status_visible != 0) { + let group_index: i32 = semantics_find_index(artifact.semantics, + linux_preview_composition_status_id()); + let heading_index: i32 = semantics_find_index(artifact.semantics, + linux_preview_composition_status_heading_id()); + let caption_index: i32 = semantics_find_index(artifact.semantics, + linux_preview_composition_status_caption_id()); + let surface_hit_index: i32 = hit_tree_find_index(artifact.hits, + linux_preview_composition_status_surface_id()); + let routed: HitResult = hit_test(artifact.hits, + artifact.layout.status.x + artifact.layout.status.width / 2, + artifact.layout.status.y + artifact.layout.status.height / 2); + let all_read_only: i32 = (group_index >= 0 && + artifact.semantics.nodes.data[group_index].role == SemanticRole.list && + composition_rect_equal( + artifact.semantics.nodes.data[group_index].bounds, + artifact.layout.status_list) != 0 && + heading_index >= 0 && caption_index >= 0 && + hit_tree_find_index(artifact.hits, + linux_preview_composition_status_id()) < 0 && + surface_hit_index >= 0 && + artifact.hits.nodes.data[surface_hit_index].enabled == 0 && + artifact.hits.nodes.data[surface_hit_index].focusable == 0 && + routed.found == 0) as i32; + let index: i32 = 0; + while (index < 5) { + let id: NodeKey = linux_preview_composition_status_item_id(index); + let semantic_index: i32 = semantics_find_index( + artifact.semantics, id); + let bounds: Rect = linux_preview_status_item_bounds( + artifact.layout, index, 5); + if (semantic_index < 0 || + artifact.semantics.nodes.data[semantic_index].role != + SemanticRole.list_item || + artifact.semantics.nodes.data[semantic_index].action_mask != 0 || + hit_tree_find_index(artifact.hits, id) >= 0 || + composition_display_has_exact_bounds( + artifact.display, id, bounds) == 0 || + composition_display_owner_has_token_bounds(artifact.display, + id, showcase_category_color(showcase_category_at(index)), + flex_spacing(FlexSpacingToken.tiny, + FlexDensity.standard), + flex_spacing(FlexSpacingToken.xlarge, + FlexDensity.standard)) == 0) { + all_read_only = 0; + } + index = index + 1; + } + expect(state, all_read_only != 0 && + artifact.status_items_composed == 5, + "expanded status rail uses documented category swatches and remains strictly read-only"); + } else { + expect(state, artifact.status_items_composed == 0 && + semantics_find_index(artifact.semantics, + linux_preview_composition_status_id()) < 0 && + hit_tree_find_index(artifact.hits, + linux_preview_composition_status_id()) < 0, + "medium and compact layouts omit the competing status rail completely"); + } +} + +fn composition_talkback_contract(state: *TestState, + artifact: LinuxPreviewCompositionArtifact) void { + let session: TalkbackSession = talkback_session_make( + talkback_capabilities()); + let overview: TalkbackResponse = talkback_dispatch(&session, + artifact.semantics, artifact.layout.viewport, + talkback_request(1, TalkbackCommand.query, + linux_preview_composition_nav_id(0), + artifact.semantics.revision)); + let components: TalkbackResponse = talkback_dispatch(&session, + artifact.semantics, artifact.layout.viewport, + talkback_request(2, TalkbackCommand.click, + linux_preview_composition_nav_id(1), + artifact.semantics.revision)); + let inspect: TalkbackResponse = talkback_dispatch(&session, + artifact.semantics, artifact.layout.viewport, + talkback_request(3, TalkbackCommand.click, + linux_preview_composition_inspect_id(), + artifact.semantics.revision)); + let inspect_available: i32 = + linux_preview_token_inspector_available(artifact.layout); + expect(state, overview.status == TalkbackStatus.accepted && + overview.resolved_role == SemanticRole.tab && + (overview.state_flags & talkback_state_selected_bit()) != 0 && + components.status == TalkbackStatus.accepted && + components.emitted_action == 1 && + (inspect_available == 0 || + (inspect.status == TalkbackStatus.accepted && + inspect.emitted_action == 1)), + "Talkback queries and activates both available routes by stable ID"); + if (artifact.layout.chart_visible != 0) { + let chart: TalkbackResponse = talkback_dispatch(&session, + artifact.semantics, artifact.layout.viewport, + talkback_request(4, TalkbackCommand.query, + linux_preview_composition_chart_id(), + artifact.semantics.revision)); + expect(state, chart.status == TalkbackStatus.accepted && + chart.has_bounds == 1 && + composition_rect_equal(chart.bounds, artifact.layout.chart) != 0, + "Talkback chart geometry is the same exact rectangle used by rendering and hits"); + } else { + let summary: TalkbackResponse = talkback_dispatch(&session, + artifact.semantics, artifact.layout.viewport, + talkback_request(4, TalkbackCommand.query, + linux_preview_composition_summary_id(), + artifact.semantics.revision)); + expect(state, summary.status == TalkbackStatus.accepted && + summary.resolved_role == SemanticRole.text && + summary.value_length > 0, + "Talkback receives compact performance truth without a pixel fallback"); + } + talkback_session_free(&session); +} + +fn composition_size_contract(state: *TestState, + face: OpenTypeFace, width: i32, height: i32, + expected_mode: LinuxPreviewLayoutMode, + expected_chart: i32, expected_status: i32) i64 { + let artifact: LinuxPreviewCompositionArtifact = + linux_preview_composition_build(width, height, face, + linux_preview_initial_navigation()); + expect(state, linux_preview_composition_valid(artifact) != 0 && + artifact.layout.mode == expected_mode && + artifact.layout.chart_visible == expected_chart && + artifact.layout.status_visible == expected_status, + "responsive composition builds one valid retained artifact for the requested viewport"); + expect(state, + composition_display_owner_has_token(artifact.display, + linux_preview_composition_header_id(), + SemanticColorToken.surface_base) != 0 && + composition_display_owner_has_token(artifact.display, + linux_preview_composition_navigation_surface_id(), + SemanticColorToken.surface_panel) != 0 && + composition_display_owner_has_token(artifact.display, + linux_preview_composition_main_id(), + SemanticColorToken.surface_raised) != 0, + "shell panel and raised material tiers produce a visible hierarchy from semantic tokens"); + composition_backdrop_contract(state, artifact); + composition_surface_contract(state, artifact); + composition_navigation_contract(state, artifact); + composition_controls_contract(state, artifact); + composition_chart_contract(state, artifact); + composition_status_contract(state, artifact); + composition_talkback_contract(state, artifact); + let raster: CpuRasterResult = cpu_rasterize( + artifact.display, width, height); + expect(state, raster.error == CpuRasterError.none && + raster.pixels_touched > 0, + "the sealed composition rasterizes through the deterministic CPU oracle"); + cpu_raster_free(&raster); + let hash: i64 = artifact.evidence_hash; + linux_preview_composition_free(&artifact); + return hash; +} + +fn unsupported_viewport_contract(state: *TestState, + face: OpenTypeFace) void { + let unsupported: LinuxPreviewCompositionArtifact = + linux_preview_composition_build(320, 240, face, 0); + let message_index: i32 = semantics_find_index(unsupported.semantics, + linux_preview_composition_unsupported_id()); + expect(state, + linux_preview_composition_valid(unsupported) != 0 && + unsupported.error == LinuxPreviewCompositionError.none && + unsupported.layout.error == + LinuxPreviewLayoutError.invalid_viewport && + unsupported.unsupported_size_composed == 1 && + message_index >= 0 && + unsupported.semantics.nodes.data[message_index].role == + SemanticRole.dialog && + unsupported.semantics.nodes.data[message_index].live_region == + LiveRegion.polite && + unsupported.navigation_items_composed == 0 && + unsupported.chart_composed == 0 && + unsupported.segmented_control_composed == 0, + "sub-minimum native viewport publishes an explicit semantic resize state instead of partial UI"); + let query: TalkbackSession = talkback_session_make( + talkback_capabilities()); + let response: TalkbackResponse = talkback_dispatch(&query, + unsupported.semantics, unsupported.layout.viewport, + talkback_request(90, TalkbackCommand.query, + linux_preview_composition_unsupported_id(), + unsupported.semantics.revision)); + expect(state, response.status == TalkbackStatus.accepted && + response.resolved_role == SemanticRole.dialog && + response.has_bounds == 1 && response.value_length > 0, + "Talkback can inspect the unsupported-size explanation by stable ID"); + talkback_session_free(&query); + let raster: CpuRasterResult = cpu_rasterize( + unsupported.display, 320, 240); + expect(state, raster.error == CpuRasterError.none && + raster.pixels_touched > 0, + "unsupported-size state remains a sealed CPU-renderable surface"); + cpu_raster_free(&raster); + linux_preview_composition_free(&unsupported); + + let impossible: LinuxPreviewCompositionArtifact = + linux_preview_composition_build(100, 100, face, 0); + expect(state, + impossible.error == LinuxPreviewCompositionError.invalid_layout && + impossible.layout.error == LinuxPreviewLayoutError.invalid_viewport && + impossible.display.ops.len == 0 && + impossible.semantics.nodes.len == 0 && + impossible.hits.nodes.len == 0 && impossible.evidence_hash == 0, + "surface too small for truthful text remains a hard fail-closed error"); + linux_preview_composition_free(&impossible); +} + +fn selected_segment_contract(state: *TestState, + face: OpenTypeFace, baseline_hash: i64) void { + let artifact: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_segment( + 1120, 720, face, 0, 2); + let state_index: i32 = semantics_find_index(artifact.semantics, + linux_preview_composition_segment_id(0)); + let motion_index: i32 = semantics_find_index(artifact.semantics, + linux_preview_composition_segment_id(2)); + let motion_series_index: i32 = semantics_find_index( + artifact.semantics, + linux_preview_composition_chart_series_id(2)); + let state_series_index: i32 = semantics_find_index( + artifact.semantics, + linux_preview_composition_chart_series_id(0)); + expect(state, linux_preview_composition_valid(artifact) != 0 && + artifact.segmented_model.selected_index == 2 && + state_index >= 0 && motion_index >= 0 && + state_series_index >= 0 && motion_series_index >= 0 && + artifact.semantics.nodes.data[state_index].selected == 0 && + artifact.semantics.nodes.data[motion_index].selected == 1 && + artifact.semantics.nodes.data[state_series_index].selected == 0 && + artifact.semantics.nodes.data[motion_series_index].selected == 1 && + artifact.evidence_hash != baseline_hash, + "application-owned segment selection changes the linked chart series pixels semantics and deterministic evidence together"); + linux_preview_composition_free(&artifact); + + let compact: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_segment( + 800, 600, face, 0, 1); + let summary_index: i32 = semantics_find_index(compact.semantics, + linux_preview_composition_summary_id()); + expect(state, linux_preview_composition_valid(compact) != 0 && + compact.chart_composed == 0 && compact.compact_summary_composed == 1 && + summary_index >= 0 && + semantic_text_equal(compact.semantics.nodes.data[summary_index].value, + "Selected fixture peak 10 milliseconds; deadline 16 milliseconds") != 0, + "compact replacement summarizes the same selected Layout fixture and sixteen millisecond deadline"); + linux_preview_composition_free(&compact); +} + +fn segment_interaction_truth_contract(state: *TestState, + face: OpenTypeFace) void { + let model: SegmentedControlModel = + linux_preview_composition_segmented_model_for(0); + let transition: SegmentedControlTransition = segmented_control_reduce( + model, segmented_control_event( + SegmentedControlEventKind.pointer_enter, 2)); + model = transition.model; + transition = segmented_control_reduce(model, segmented_control_event( + SegmentedControlEventKind.pointer_down, 2)); + model = transition.model; + transition = segmented_control_reduce(model, segmented_control_event( + SegmentedControlEventKind.pointer_up_inside, 2)); + model = transition.model; + + let pointer_artifact: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_model( + 1120, 720, face, 0, model); + let pointer_index: i32 = semantics_find_index( + pointer_artifact.semantics, + linux_preview_composition_segment_id(2)); + let pointer_spec: SegmentedControlSpec = + linux_preview_composition_segmented_spec(pointer_artifact.layout); + pointer_spec.model = model; + let pointer_style: SegmentedControlResolvedStyle = + segmented_control_style(pointer_spec, 2); + let session: TalkbackSession = talkback_session_make( + talkback_capabilities()); + let pointer_query: TalkbackResponse = talkback_dispatch(&session, + pointer_artifact.semantics, pointer_artifact.layout.viewport, + talkback_request(120, TalkbackCommand.query, + linux_preview_composition_segment_id(2), + pointer_artifact.semantics.revision)); + let pointer_hash: i64 = pointer_artifact.evidence_hash; + expect(state, linux_preview_composition_valid(pointer_artifact) != 0 && + model.selected_index == 2 && model.roving_index == 2 && + model.has_focus == 1 && model.focus_visible == 0 && + model.hovered_index == 2 && pointer_index >= 0 && + pointer_artifact.semantics.nodes.data[pointer_index].focused == 1 && + pointer_style.has_focus == 1 && pointer_style.focus_visible == 0 && + pointer_style.focused == 0 && pointer_style.hovered == 1 && + pointer_query.status == TalkbackStatus.accepted && + pointer_query.state_flags == talkback_state_selected_bit() + + talkback_state_focused_bit(), + "pointer interaction drives real hover and semantic focus without faking a keyboard focus ring"); + linux_preview_composition_free(&pointer_artifact); + + transition = segmented_control_reduce(model, segmented_control_event( + SegmentedControlEventKind.key_next, + segmented_control_no_index())); + model = transition.model; + let keyboard_artifact: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_model( + 1120, 720, face, 0, model); + let keyboard_index: i32 = semantics_find_index( + keyboard_artifact.semantics, + linux_preview_composition_segment_id(0)); + let keyboard_spec: SegmentedControlSpec = + linux_preview_composition_segmented_spec(keyboard_artifact.layout); + keyboard_spec.model = model; + let keyboard_style: SegmentedControlResolvedStyle = + segmented_control_style(keyboard_spec, 0); + expect(state, linux_preview_composition_valid(keyboard_artifact) != 0 && + model.selected_index == 0 && model.roving_index == 0 && + model.has_focus == 1 && model.focus_visible == 1 && + keyboard_index >= 0 && + keyboard_artifact.semantics.nodes.data[keyboard_index].focused == 1 && + keyboard_style.focused == 1 && keyboard_style.focus_visible == 1 && + keyboard_artifact.evidence_hash != pointer_hash, + "keyboard transition skips the disabled segment and makes its actual focus ring observable in evidence"); + linux_preview_composition_free(&keyboard_artifact); + talkback_session_free(&session); +} + +fn general_interaction_truth_contract(state: *TestState, + face: OpenTypeFace) void { + let model: SegmentedControlModel = + linux_preview_composition_segmented_model_for(0); + let target: NodeKey = linux_preview_composition_inspect_id(); + + let hover_state: LinuxPreviewInteractionState = + linux_preview_interaction_state(); + hover_state.hovered = target; + let hovered: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_interaction_state( + 1360, 900, face, 0, model, 0, hover_state); + let hover_semantic: i32 = semantics_find_index( + hovered.semantics, target); + expect(state, linux_preview_composition_valid(hovered) != 0 && + hover_semantic >= 0 && + hovered.semantics.nodes.data[hover_semantic].focused == 0 && + node_key_equal(hovered.interaction.hovered, target) != 0 && + composition_display_owner_has_token_alpha(hovered.display, + target, SemanticColorToken.accent_emphasis, 61000) != 0, + "real pointer hover changes the canonical Inspect Button without inventing semantic focus"); + let hover_hash: i64 = hovered.evidence_hash; + linux_preview_composition_free(&hovered); + + let pressed_state: LinuxPreviewInteractionState = + linux_preview_interaction_state(); + pressed_state.hovered = target; + pressed_state.pressed = target; + let pressed: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_interaction_state( + 1360, 900, face, 0, model, 0, pressed_state); + expect(state, linux_preview_composition_valid(pressed) != 0 && + node_key_equal(pressed.interaction.pressed, target) != 0 && + composition_display_owner_has_token_alpha(pressed.display, + target, SemanticColorToken.accent_emphasis, 65535) != 0 && + pressed.evidence_hash != hover_hash, + "real pointer press has a distinct canonical fill depth and deterministic state identity"); + let pressed_hash: i64 = pressed.evidence_hash; + linux_preview_composition_free(&pressed); + + let focus_state: LinuxPreviewInteractionState = + linux_preview_interaction_state(); + focus_state.focused = target; + focus_state.focus_visible = 1; + let focused: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_interaction_state( + 1360, 900, face, 0, model, 0, focus_state); + let focus_semantic: i32 = semantics_find_index( + focused.semantics, target); + let session: TalkbackSession = talkback_session_make( + talkback_capabilities()); + let query: TalkbackResponse = talkback_dispatch(&session, + focused.semantics, focused.layout.viewport, + talkback_request(150, TalkbackCommand.query, target, + focused.semantics.revision)); + expect(state, linux_preview_composition_valid(focused) != 0 && + focus_semantic >= 0 && + focused.semantics.nodes.data[focus_semantic].focused == 1 && + focused.interaction.focus_visible == 1 && + composition_display_owner_has_token_alpha(focused.display, + target, SemanticColorToken.focus, 39000) != 0 && + query.status == TalkbackStatus.accepted && + query.state_flags == talkback_state_focused_bit() && + focused.evidence_hash != hover_hash && + focused.evidence_hash != pressed_hash, + "keyboard focus is simultaneously visible, semantic, Talkback-queryable, and distinct from hover"); + talkback_session_free(&session); + linux_preview_composition_free(&focused); + + let compact_state: LinuxPreviewInteractionState = + linux_preview_interaction_state(); + compact_state.focused = target; + compact_state.focus_visible = 1; + let compact: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_interaction_state( + 800, 600, face, 0, model, 0, compact_state); + let compact_index: i32 = semantics_find_index(compact.semantics, target); + expect(state, linux_preview_composition_valid(compact) != 0 && + node_key_equal(compact.interaction.focused, + hit_root_key()) != 0 && + compact.interaction.focus_visible == 0 && compact_index >= 0 && + compact.semantics.nodes.data[compact_index].disabled == 1, + "responsive normalization clears focus when the Inspect target becomes unavailable"); + linux_preview_composition_free(&compact); + + let invalid_state: LinuxPreviewInteractionState = + linux_preview_interaction_state(); + invalid_state.focus_visible = 1; + let invalid: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_interaction_state( + 1120, 720, face, 0, model, 0, invalid_state); + expect(state, + invalid.error == LinuxPreviewCompositionError.invalid_layout && + invalid.evidence_hash == 0, + "focus-visible without an actual focus owner fails closed"); + linux_preview_composition_free(&invalid); +} + +fn token_inspector_contract(state: *TestState, + face: OpenTypeFace, baseline_hash: i64) void { + let model: SegmentedControlModel = + linux_preview_composition_segmented_model_for(0); + let open: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_state( + 1360, 900, face, 0, model, 1); + let inspector_index: i32 = semantics_find_index(open.semantics, + linux_preview_composition_token_inspector_id()); + let button_index: i32 = semantics_find_index(open.semantics, + linux_preview_composition_inspect_id()); + let material_line_index: i32 = semantics_find_index(open.semantics, + linux_preview_composition_token_inspector_line_id(3)); + let all_lines: i32 = 1; + let index: i32 = 0; + while (index < 10) { + let line_id: NodeKey = + linux_preview_composition_token_inspector_line_id(index); + if (semantics_find_index(open.semantics, line_id) < 0 || + composition_draw_path_count(open.display, line_id) != 1) { + all_lines = 0; + } + index = index + 1; + } + expect(state, linux_preview_composition_valid(open) != 0 && + open.token_inspector_open == 1 && + open.token_inspector_composed == 1 && + open.token_inspector_text_instances == 10 && + open.surfaces_composed == 5 && inspector_index >= 0 && + open.semantics.nodes.data[inspector_index].role == + SemanticRole.group && + composition_semantic_hit_bounds_match(open, + linux_preview_composition_token_inspector_id(), + linux_preview_token_inspector_bounds(open.layout)) != 0 && + all_lines != 0 && open.evidence_hash != baseline_hash, + "Inspect toggles a real overlay Surface with ten visible semantic provenance lines"); + expect(state, button_index >= 0 && material_line_index >= 0 && + open.semantics.nodes.data[button_index].selected == 1 && + semantic_text_equal(open.semantics.nodes.data[button_index].name, + "Close inspector") != 0 && + semantic_text_equal(open.semantics.nodes.data[material_line_index].name, + "Material · material.glass.raised") != 0, + "inspector control state and visible material token ID agree in semantics"); + let session: TalkbackSession = talkback_session_make( + talkback_capabilities()); + let query: TalkbackResponse = talkback_dispatch(&session, + open.semantics, open.layout.viewport, + talkback_request(200, TalkbackCommand.query, + linux_preview_composition_token_inspector_line_id(9), + open.semantics.revision)); + expect(state, query.status == TalkbackStatus.accepted && + query.resolved_role == SemanticRole.text && + query.action_mask == 0 && query.name_length > 0, + "Talkback reads the exact material fallback line without a pointer target"); + talkback_session_free(&session); + linux_preview_composition_free(&open); + + let compact: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_state( + 800, 600, face, 0, model, 1); + let compact_button: i32 = semantics_find_index(compact.semantics, + linux_preview_composition_inspect_id()); + expect(state, linux_preview_composition_valid(compact) != 0 && + linux_preview_token_inspector_available(compact.layout) == 0 && + compact.token_inspector_open == 0 && + compact.token_inspector_composed == 0 && compact_button >= 0 && + compact.semantics.nodes.data[compact_button].disabled == 1 && + compact.semantics.nodes.data[compact_button].action_mask == 0, + "a viewport that cannot fit the inspector disables the control instead of clipping an overlay"); + linux_preview_composition_free(&compact); +} + +fn components_route_contract(state: *TestState, + face: OpenTypeFace) void { + let app: LinuxPreviewAppState = linux_preview_app_state(); + app.selected_navigation = 1; + let artifact: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_app_state( + 1120, 720, face, app); + let viewport_id: NodeKey = + linux_preview_composition_components_viewport_id(); + let gallery_id: NodeKey = + linux_preview_composition_component_gallery_id(); + let viewport_index: i32 = semantics_find_index( + artifact.semantics, viewport_id); + let gallery_index: i32 = semantics_find_index( + artifact.semantics, gallery_id); + let nav_index: i32 = semantics_find_index(artifact.semantics, + linux_preview_composition_nav_id(1)); + expect(state, linux_preview_composition_valid(artifact) != 0 && + artifact.selected_navigation == 1 && + artifact.components_available == 1 && + artifact.component_gallery_composed == 1 && + artifact.component_viewport_composed == 1 && + artifact.chart_composed == 0 && + artifact.segmented_control_composed == 0 && + artifact.typography_composed == 0 && + artifact.inspect_composed == 0 && + viewport_index >= 0 && gallery_index >= 0 && nav_index >= 0 && + artifact.semantics.nodes.data[nav_index].selected == 1 && + artifact.semantics.nodes.data[viewport_index].role == + SemanticRole.group && + node_key_equal(artifact.semantics.nodes.data[gallery_index].parent, + viewport_id) != 0 && + composition_rect_equal( + artifact.semantics.nodes.data[viewport_index].bounds, + artifact.layout.route_content) != 0 && + semantics_find_index(artifact.semantics, + linux_preview_composition_chart_id()) < 0 && + semantics_find_index(artifact.semantics, + linux_preview_composition_segmented_id()) < 0 && + semantics_find_index(artifact.semantics, + linux_preview_composition_inspect_id()) < 0, + "Components is one exclusive gallery-plus-viewport route with no Overview fallthrough"); + + let horizontal_index: i32 = semantics_find_index(artifact.semantics, + scroll_viewport_horizontal_range_id(viewport_id)); + let vertical_index: i32 = semantics_find_index(artifact.semantics, + scroll_viewport_vertical_range_id(viewport_id)); + expect(state, + artifact.app_state.components_scroll.viewport.width == + artifact.layout.route_content.width && + artifact.app_state.components_scroll.viewport.height == + artifact.layout.route_content.height && + artifact.app_state.components_scroll.content.width == + 1072 * unit_scale() && + artifact.app_state.components_scroll.content.height == + 1192 * unit_scale() && + artifact.component_viewport.geometry.horizontal_track_visible == 1 && + artifact.component_viewport.geometry.vertical_track_visible == 1 && + horizontal_index >= 0 && vertical_index >= 0 && + artifact.semantics.nodes.data[horizontal_index].role == + SemanticRole.slider && + artifact.semantics.nodes.data[vertical_index].role == + SemanticRole.slider && + artifact.component_viewport_state_hash == + scroll_state_hash(artifact.app_state.components_scroll), + "the fixed 1072 by 1192 gallery has one retained two-axis geometry and range truth"); + + let session: TalkbackSession = talkback_session_make( + talkback_capabilities()); + let scroll_request: TalkbackRequest = talkback_scroll_request( + 300, viewport_id, artifact.semantics.revision, + TalkbackScrollAxis.both, + 32 * unit_scale(), 48 * unit_scale()); + let scroll_response: TalkbackResponse = talkback_dispatch(&session, + artifact.semantics, artifact.layout.viewport, scroll_request); + expect(state, scroll_response.status == TalkbackStatus.accepted && + scroll_response.emitted_action == 1 && + session.events.len == 1 && + session.events.data[0].scroll_axis == TalkbackScrollAxis.both && + session.events.data[0].scroll_delta_x == 32 * unit_scale() && + session.events.data[0].scroll_delta_y == 48 * unit_scale(), + "Talkback publishes an explicit stable-ID two-axis scroll payload"); + + let scrolled_state: LinuxPreviewAppState = artifact.app_state; + let scroll_transition: ScrollViewportTransition = + linux_preview_app_state_scroll_components(&scrolled_state, + scroll_request.scroll_delta_x, + scroll_request.scroll_delta_y); + expect(state, scroll_transition.accepted == 1 && + scroll_transition.mutation.changed == 1 && + scrolled_state.components_scroll.offset_x == 32 * unit_scale() && + scrolled_state.components_scroll.offset_y == 48 * unit_scale(), + "the accepted Talkback payload mutates the same retained ScrollState used by composition"); + + let offscreen_id: NodeKey = component_state_gallery_segment_id( + gallery_id, 0); + let offscreen_query: TalkbackResponse = talkback_dispatch(&session, + artifact.semantics, artifact.layout.viewport, + talkback_request(301, TalkbackCommand.query, offscreen_id, + artifact.semantics.revision)); + let reveal_state: LinuxPreviewAppState = artifact.app_state; + let reveal: ScrollViewportTargetReveal = + linux_preview_app_state_reveal_component( + &reveal_state, &artifact, offscreen_id); + expect(state, offscreen_query.status == TalkbackStatus.accepted && + offscreen_query.has_bounds == 1 && reveal.found == 1 && + reveal.focusable == 1 && reveal.transition.accepted == 1 && + reveal.transition.mutation.changed == 1 && + reveal_state.components_scroll.offset_y > 0, + "offscreen semantic query succeeds and ID reveal changes ScrollState without a pixel map"); + let baseline_hash: i64 = artifact.evidence_hash; + linux_preview_composition_free(&artifact); + + let revealed: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_app_state( + 1120, 720, face, reveal_state); + let revealed_query: TalkbackResponse = talkback_dispatch(&session, + revealed.semantics, revealed.layout.viewport, + talkback_request(302, TalkbackCommand.query, offscreen_id, + revealed.semantics.revision)); + expect(state, linux_preview_composition_valid(revealed) != 0 && + revealed.evidence_hash != baseline_hash && + revealed_query.status == TalkbackStatus.accepted && + revealed_query.bounds.y < + revealed.layout.route_content.y + + revealed.layout.route_content.height && + revealed_query.bounds.y + revealed_query.bounds.height > + revealed.layout.route_content.y, + "rebuild resolves the same target ID inside the viewport and changes deterministic evidence"); + + let resize_state: LinuxPreviewAppState = scrolled_state; + let before_x: i64 = resize_state.components_scroll.offset_x; + let before_y: i64 = resize_state.components_scroll.offset_y; + let resized: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_app_state( + 1360, 900, face, resize_state); + expect(state, linux_preview_composition_valid(resized) != 0 && + resized.selected_navigation == 1 && + resized.app_state.components_scroll.viewport.width == + resized.layout.route_content.width && + resized.app_state.components_scroll.viewport.height == + resized.layout.route_content.height && + resized.app_state.components_scroll.offset_x == before_x && + resized.app_state.components_scroll.offset_y == before_y, + "resize reconciles viewport geometry while preserving the retained top-left content anchor"); + linux_preview_composition_free(&resized); + linux_preview_composition_free(&revealed); + talkback_session_free(&session); + + let bad: LinuxPreviewAppState = linux_preview_app_state(); + bad.selected_navigation = 1; + bad.components_scroll.content.width = 0; + let rejected: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_app_state( + 1120, 720, face, bad); + expect(state, + rejected.error == LinuxPreviewCompositionError.invalid_layout && + rejected.evidence_hash == 0 && rejected.display.ops.len == 0 && + rejected.semantics.nodes.len == 0 && rejected.hits.nodes.len == 0, + "invalid retained route state fails before any partial destination contribution"); + linux_preview_composition_free(&rejected); +} + +fn typography_route_contract(state: *TestState, + light: *OpenTypeFace, + regular: *OpenTypeFace, + bold: *OpenTypeFace) void { + let model: SegmentedControlModel = + linux_preview_composition_segmented_model_for(0); + let overview: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_fonts_state( + 1360, 900, light, regular, bold, 0, model, 0); + let overview_typography_nav: i32 = semantics_find_index( + overview.semantics, linux_preview_composition_nav_id(3)); + expect(state, linux_preview_composition_valid(overview) != 0 && + overview.selected_navigation == 0 && + overview.typography_available == 1 && + overview.typography_composed == 0 && + overview.chart_composed == 1 && overview_typography_nav >= 0 && + overview.semantics.nodes.data[overview_typography_nav].disabled == 0 && + overview.semantics.nodes.data[overview_typography_nav].action_mask != 0, + "expanded Overview exposes the real Typography destination without replacing the chart prematurely"); + linux_preview_composition_free(&overview); + + let artifact: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_fonts_state( + 1360, 900, light, regular, bold, 3, model, 0); + let nav_index: i32 = semantics_find_index(artifact.semantics, + linux_preview_composition_nav_id(3)); + let root_index: i32 = semantics_find_index(artifact.semantics, + linux_preview_composition_typography_id()); + let code_index: i32 = semantics_find_index(artifact.semantics, + typography_specimen_sample_id( + linux_preview_composition_typography_id(), 6)); + expect(state, linux_preview_composition_valid(artifact) != 0 && + artifact.selected_navigation == 3 && + artifact.typography_available == 1 && + artifact.typography_composed == 1 && + artifact.typography_rows_composed == + typography_specimen_row_count() && + artifact.typography_display_operations == + typography_specimen_row_count() * 10 && + artifact.typography_display_resources == + typography_specimen_row_count() * 2 && + artifact.typography_semantic_nodes == + 1 + typography_specimen_row_count() * 3 && + artifact.chart_composed == 0 && + artifact.segmented_control_composed == 0 && + nav_index >= 0 && root_index >= 0 && code_index >= 0 && + artifact.semantics.nodes.data[nav_index].selected == 1 && + artifact.semantics.nodes.data[nav_index].disabled == 0 && + composition_rect_equal( + artifact.semantics.nodes.data[root_index].bounds, + linux_preview_typography_bounds(artifact.layout)) != 0, + "expanded Typography navigation hosts the complete thirteen-row ramp under shared bounds without chart-shaped filler"); + linux_preview_composition_free(&artifact); + + let medium: LinuxPreviewCompositionArtifact = + linux_preview_composition_build_with_fonts_state( + 1120, 720, light, regular, bold, 3, model, 0); + let medium_nav: i32 = semantics_find_index(medium.semantics, + linux_preview_composition_nav_id(3)); + expect(state, linux_preview_composition_valid(medium) != 0 && + medium.selected_navigation == 0 && + medium.typography_available == 0 && + medium.typography_composed == 0 && medium_nav >= 0 && + medium.semantics.nodes.data[medium_nav].disabled == 1 && + medium.semantics.nodes.data[medium_nav].action_mask == 0, + "Typography route fails closed at medium size instead of clipping its required type ramp"); + linux_preview_composition_free(&medium); +} + +fn composition_face(path: []u8) OpenTypeFace { + let bytes: []u8 = _zag_read_file(path); + if (bytes.len <= 0) { + if (bytes.len >= 0) { _zag_free(_zag_slice_ptr(bytes)); } + return opentype_face_empty(); + } + let face: OpenTypeFace = opentype_parse(bytes); + _zag_free(_zag_slice_ptr(bytes)); + return face; +} + +fn main() i32 { + if (_zag_argc() != 4) { + _zag_eprintln("usage: linux-preview-composition-contract "); + _zag_eprintln(" Three DISTINCT font weights are required: ExtraLight/Thin, Regular, Bold."); + _zag_eprintln(" The typography specimen validates that light/regular/bold probe hashes"); + _zag_eprintln(" are non-zero and pairwise distinct. Passing the same file for all three"); + _zag_eprintln(" weights causes TypographySpecimenError.weight_not_visible."); + _zag_eprintln(" Example: DejaVuSans-ExtraLight.ttf DejaVuSans.ttf DejaVuSans-Bold.ttf"); + return 2; + } + let light: OpenTypeFace = composition_face(_zag_arg(1)); + let face: OpenTypeFace = composition_face(_zag_arg(2)); + let bold: OpenTypeFace = composition_face(_zag_arg(3)); + if (light.error != OpenTypeError.none || + face.error != OpenTypeError.none || + bold.error != OpenTypeError.none) { + opentype_face_free(&bold); + opentype_face_free(&face); + opentype_face_free(&light); + _zag_eprintln("Linux preview composition: OpenType parse failed"); + return 4; + } + + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + let medium_hash: i64 = composition_size_contract(&state, face, + 1120, 720, LinuxPreviewLayoutMode.medium, 1, 0); + let expanded_hash: i64 = composition_size_contract(&state, face, + 1360, 900, LinuxPreviewLayoutMode.expanded, 1, 1); + let compact_hash: i64 = composition_size_contract(&state, face, + 800, 600, LinuxPreviewLayoutMode.compact, 0, 0); + let repeated_hash: i64 = composition_size_contract(&state, face, + 1120, 720, LinuxPreviewLayoutMode.medium, 1, 0); + expect(&state, medium_hash != 0 && medium_hash == repeated_hash && + medium_hash != expanded_hash && medium_hash != compact_hash && + expanded_hash != compact_hash, + "identical composition is deterministic while responsive geometry changes evidence"); + token_inspector_contract(&state, face, medium_hash); + selected_segment_contract(&state, face, medium_hash); + segment_interaction_truth_contract(&state, face); + general_interaction_truth_contract(&state, face); + components_route_contract(&state, face); + typography_route_contract(&state, &light, &face, &bold); + unsupported_viewport_contract(&state, face); + + opentype_face_free(&bold); + opentype_face_free(&face); + opentype_face_free(&light); + _zag_print("Linux preview composition: pass="); + _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); + _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tests/linux_preview_semantics_contract.zag b/tests/linux_preview_semantics_contract.zag index 6d5378b..630e3a8 100644 --- a/tests/linux_preview_semantics_contract.zag +++ b/tests/linux_preview_semantics_contract.zag @@ -18,7 +18,7 @@ fn main() i32 { expect(&state, tree.last_error == SemanticsError.none && tree.nodes.len == linux_preview_semantic_node_count(1120, 720), "the native preview exposes one complete owned semantic tree"); - let components_index: i32 = semantics_find_index(tree, linux_preview_nav_id(1)); + let components_index: i32 = semantics_find_index(tree, linux_preview_nav_id(2)); let overview_index: i32 = semantics_find_index(tree, linux_preview_nav_id(0)); expect(&state, components_index >= 0 && overview_index >= 0 && tree.nodes.data[components_index].selected == 0 && @@ -28,7 +28,7 @@ fn main() i32 { tree.nodes.data[overview_index].disabled == 0, "navigation selection truth uses stable semantic IDs"); let expected_components: Rect = linux_preview_navigation_item_bounds( - medium_layout, 1, linux_preview_navigation_count()); + medium_layout, 2, linux_preview_navigation_count()); expect(&state, tree.nodes.data[components_index].bounds.x == expected_components.x && tree.nodes.data[components_index].bounds.y == expected_components.y && @@ -39,7 +39,7 @@ fn main() i32 { let query: TalkbackResponse = talkback_dispatch(&session, tree, rect(0, 0, 1120 * unit_scale(), 720 * unit_scale()), talkback_request(1, TalkbackCommand.query, - linux_preview_nav_id(1), tree.revision)); + linux_preview_nav_id(2), tree.revision)); expect(&state, query.status == TalkbackStatus.accepted && query.resolved_role == SemanticRole.tab && query.has_bounds == 1 && (query.state_flags & 1) != 0, @@ -47,7 +47,7 @@ fn main() i32 { let disabled_click: TalkbackResponse = talkback_dispatch(&session, tree, rect(0, 0, 1120 * unit_scale(), 720 * unit_scale()), talkback_request(3, TalkbackCommand.click, - linux_preview_nav_id(1), tree.revision)); + linux_preview_nav_id(2), tree.revision)); expect(&state, disabled_click.status == TalkbackStatus.action_unavailable, "unfinished navigation destinations fail closed instead of changing a highlight"); let click: TalkbackResponse = talkback_dispatch(&session, tree, @@ -101,9 +101,9 @@ fn main() i32 { let overview_bounds: Rect = linux_preview_navigation_item_bounds( medium_layout, 0, linux_preview_navigation_count()); let component_bounds: Rect = linux_preview_navigation_item_bounds( - medium_layout, 1, linux_preview_navigation_count()); + medium_layout, 2, linux_preview_navigation_count()); let expanded_component_bounds: Rect = linux_preview_navigation_item_bounds( - expanded_layout, 1, linux_preview_navigation_count()); + expanded_layout, 2, linux_preview_navigation_count()); expect(&state, linux_preview_navigation_at_pixel( ((overview_bounds.x + overview_bounds.width / 2) / unit_scale()) as i32, ((overview_bounds.y + overview_bounds.height / 2) / unit_scale()) as i32, @@ -111,7 +111,7 @@ fn main() i32 { linux_preview_navigation_hit_at_pixel( ((component_bounds.x + component_bounds.width / 2) / unit_scale()) as i32, ((component_bounds.y + component_bounds.height / 2) / unit_scale()) as i32, - 1120, 720) == 1 && + 1120, 720) == 2 && linux_preview_navigation_at_pixel( ((component_bounds.x + component_bounds.width / 2) / unit_scale()) as i32, ((component_bounds.y + component_bounds.height / 2) / unit_scale()) as i32, diff --git a/tests/linux_x11_event_contract.zag b/tests/linux_x11_event_contract.zag new file mode 100644 index 0000000..2c7b5d6 --- /dev/null +++ b/tests/linux_x11_event_contract.zag @@ -0,0 +1,157 @@ +@import("../src/platform/linux/x11.zag") + +struct TestState { passed: i32, failed: i32 } +fn expect(state: *TestState, condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; _zag_print("FAIL "); _zag_println(name); + } +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + let layout_ok: i32 = 0; + let stride_ok: i32 = 0; + let decoded_ok: i32 = 0; + let released_ok: i32 = 0; + let motion_ok: i32 = 0; + let key_ok: i32 = 0; + let surface_ok: i32 = 0; + let store_ok: i32 = 0; + let identity_ok: i32 = 0; + let image_prefix_ok: i32 = 0; + let wheel_ok: i32 = 0; + let wheel_release_ok: i32 = 0; + unsafe { + let raw: *u8 = _zag_malloc(192) as *u8; + let i: i32 = 0; + while (i < 192) { raw[i] = 170; i = i + 1; } + let native: *LinuxX11ButtonEventLayout = + raw as *LinuxX11ButtonEventLayout; + + layout_ok = (@sizeOf[LinuxX11ButtonEventLayout]() == 96 && + ((&native.*.x) as i64) - (native as i64) == 64 && + ((&native.*.y) as i64) - (native as i64) == 68 && + ((&native.*.button) as i64) - (native as i64) == 84) as i32; + let next: *LinuxX11ButtonEventLayout = &native[1]; + stride_ok = (((next as i64) - (native as i64)) == 96) as i32; + + // Known little-endian bytes emulate one event written by Xlib. + raw[64] = 156; raw[65] = 1; raw[66] = 0; raw[67] = 0; + raw[68] = 233; raw[69] = 0; raw[70] = 0; raw[71] = 0; + raw[80] = 5; raw[81] = 0; raw[82] = 0; raw[83] = 0; + raw[84] = 1; raw[85] = 0; raw[86] = 0; raw[87] = 0; + let decoded: LinuxX11Event = linux_x11_button_event(native, 1120, 720); + decoded_ok = (decoded.kind == LinuxX11EventKind.pointer_down && + decoded.x == 412 && decoded.y == 233 && decoded.button == 1 && + decoded.modifiers == 5) as i32; + let released: LinuxX11Event = linux_x11_button_release_event( + native, 1120, 720); + released_ok = (released.kind == LinuxX11EventKind.pointer_up && + released.x == 412 && released.y == 233 && + released.button == 1 && released.modifiers == 5) as i32; + surface_ok = (decoded.width == 1120 && decoded.height == 720) as i32; + + let motion_native: *LinuxX11MotionEventLayout = + raw as *LinuxX11MotionEventLayout; + motion_native.*.x = 201; + motion_native.*.y = 177; + motion_native.*.state = 64; + motion_native.*.is_hint = 0; + let motion: LinuxX11Event = linux_x11_motion_event( + motion_native, 900, 700); + motion_ok = (@sizeOf[LinuxX11MotionEventLayout]() == 96 && + ((&motion_native.*.state) as i64) - + (motion_native as i64) == 80 && + ((&motion_native.*.is_hint) as i64) - + (motion_native as i64) == 84 && + motion.kind == LinuxX11EventKind.pointer_move && + motion.x == 201 && motion.y == 177 && + motion.modifiers == 64 && motion.width == 900) as i32; + + let key_native: *LinuxX11KeyEventLayout = + raw as *LinuxX11KeyEventLayout; + key_native.*.keycode = 38; + key_native.*.state = 1; + let key_down: LinuxX11Event = linux_x11_key_event( + key_native, 800, 600, 1); + let key_up: LinuxX11Event = linux_x11_key_event( + key_native, 800, 600, 0); + key_ok = (@sizeOf[LinuxX11KeyEventLayout]() == 96 && + ((&key_native.*.keycode) as i64) - + (key_native as i64) == 84 && + key_down.kind == LinuxX11EventKind.key_down && + key_up.kind == LinuxX11EventKind.key_up && + key_down.keycode == 38 && key_down.modifiers == 1) as i32; + + // Narrow field stores must not overwrite adjacent C fields or padding. + raw[63] = 91; raw[68] = 77; + native.*.x = 9; + let x_store_safe: i32 = (raw[63] == 91 && raw[68] == 77) as i32; + native.*.y = 17; + raw[83] = 92; raw[88] = 93; + native.*.button = 3; + store_ok = (x_store_safe != 0 && raw[83] == 92 && raw[88] == 93) as i32; + let secondary: LinuxX11Event = linux_x11_button_event(native, 800, 600); + identity_ok = (secondary.button == 3 && secondary.x == 9 && + secondary.y == 17 && secondary.width == 800 && + secondary.height == 600) as i32; + + native.*.button = 4; + let wheel_up: LinuxX11Event = linux_x11_button_event(native, 800, 600); + let wheel_up_release: LinuxX11Event = linux_x11_button_release_event( + native, 800, 600); + native.*.button = 5; + let wheel_down: LinuxX11Event = linux_x11_button_event(native, 800, 600); + native.*.button = 6; + let wheel_left: LinuxX11Event = linux_x11_button_event(native, 800, 600); + native.*.button = 7; + let wheel_right: LinuxX11Event = linux_x11_button_event(native, 800, 600); + wheel_ok = (wheel_up.kind == LinuxX11EventKind.wheel && + wheel_up.wheel_delta_x == 0 && + wheel_up.wheel_delta_y == 0 - linux_x11_wheel_step() && + wheel_down.wheel_delta_y == linux_x11_wheel_step() && + wheel_left.wheel_delta_x == 0 - linux_x11_wheel_step() && + wheel_right.wheel_delta_x == linux_x11_wheel_step() && + wheel_right.wheel_delta_y == 0) as i32; + wheel_release_ok = (wheel_up_release.kind == LinuxX11EventKind.none && + wheel_up_release.wheel_delta_x == 0 && + wheel_up_release.wheel_delta_y == 0) as i32; + + let image_prefix: *LinuxX11ImageDataPrefix = + raw as *LinuxX11ImageDataPrefix; + image_prefix_ok = (@sizeOf[LinuxX11ImageDataPrefix]() == 24 && + ((&image_prefix.*.data) as i64) - + (image_prefix as i64) == 16) as i32; + _zag_free(raw); + } + expect(&state, layout_ok, + "Zag C layout matches XButtonEvent size and routed field offsets"); + expect(&state, stride_ok, + "typed pointer indexing uses the complete XButtonEvent C stride"); + expect(&state, decoded_ok, + "foreign XButtonEvent bytes preserve coordinates and pressed button"); + expect(&state, released_ok, + "button release retains the same typed coordinate and identity layout"); + expect(&state, motion_ok, + "XMotionEvent layout preserves pointer position and modifier state"); + expect(&state, key_ok, + "XKeyEvent layout preserves keycode phase and modifier state"); + expect(&state, surface_ok, + "decoded pointer events retain the active surface dimensions"); + expect(&state, store_ok, + "i32 and u32 field stores stay within their C-width boundaries"); + expect(&state, identity_ok, + "button identity is event-owned rather than queried after release"); + expect(&state, wheel_ok, + "X11 buttons four through seven normalize to signed two-axis wheel deltas"); + expect(&state, wheel_release_ok, + "X11 wheel releases are suppressed instead of becoming pointer-up actions"); + expect(&state, image_prefix_ok, + "XImage data detachment uses the public C field at its ABI offset"); + _zag_print("Linux X11 event ABI: pass="); _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tests/navigation_item_contract.zag b/tests/navigation_item_contract.zag index 763f7bf..a691f9d 100644 --- a/tests/navigation_item_contract.zag +++ b/tests/navigation_item_contract.zag @@ -267,7 +267,7 @@ fn deterministic_hash_contract(state: *TestState) void { expect(state, first.error == NavigationItemBuildError.none && second.error == NavigationItemBuildError.none && first.artifact.evidence_hash != 0 && - first.artifact.evidence_hash == -8023176794990020306 && + first.artifact.evidence_hash == -3727622835224814674 && first.artifact.evidence_hash == second.artifact.evidence_hash && display_a.content_hash == display_b.content_hash && talkback_tree_hash(semantics_a) == talkback_tree_hash(semantics_b), diff --git a/tests/negative/canvas_double_free.zag b/tests/negative/canvas_double_free.zag new file mode 100644 index 0000000..ae50b96 --- /dev/null +++ b/tests/negative/canvas_double_free.zag @@ -0,0 +1,12 @@ +@import("../../src/components/canvas.zag") + +fn main() i32 { + let canvas: Canvas = canvas_make( + canvas_spec(node_key(9901), + rect(0, 0, 120 * unit_scale(), 80 * unit_scale()), + CanvasSemanticsPolicy.decorative, ""), + canvas_limits_default()); + canvas_free(&canvas); + canvas_free(&canvas); + return 0; +} diff --git a/tests/negative/surface_return_lifetime.zag b/tests/negative/surface_return_lifetime.zag new file mode 100644 index 0000000..a311089 --- /dev/null +++ b/tests/negative/surface_return_lifetime.zag @@ -0,0 +1,30 @@ +@import("../../src/components/surface.zag") + +fn append_text(owner: *ArrayList[u8], + text: []u8) void { + let index: i32 = 0; + while (index < text.len) { + push[u8](owner, text[index]); + index = index + 1; + } +} + +fn main() i32 { + let owner: ArrayList[u8] = make[u8](32); + append_text(&owner, "Released too early"); + let spec: SurfaceSpec = surface_spec(node_key(9900), hit_root_key(), + rect(0, 0, 200 * unit_scale(), 80 * unit_scale()), + owner.data[0..owner.len], SurfaceTier.raised); + free[u8](&owner); + + let display: DisplayList = display_list_make(); + let hits: HitTree = hit_tree_make(); + let semantics: SemanticsTree = semantics_tree_make(); + let built: SurfaceBuildResult = surface_emit(spec, + &display, &hits, &semantics); + semantics_tree_free(&semantics); + hit_tree_free(&hits); + display_list_free(&display); + if (built.error == SurfaceBuildError.none) { return 0; } + return 1; +} diff --git a/tests/performance_chart_contract.zag b/tests/performance_chart_contract.zag index eefa09f..1b52d48 100644 --- a/tests/performance_chart_contract.zag +++ b/tests/performance_chart_contract.zag @@ -266,7 +266,7 @@ fn deterministic_contract(state: *TestState, spec: PerformanceChartSpec, first: *PerformanceChartArtifact) void { let second: PerformanceChartArtifact = performance_chart_build(spec); expect(state, second.error == PerformanceChartError.none && - first.*.evidence_hash == 1042463816 && + first.*.evidence_hash == 767263707 && second.evidence_hash == first.*.evidence_hash && second.canvas.display.content_hash == first.*.canvas.display.content_hash, @@ -286,7 +286,7 @@ fn deterministic_contract(state: *TestState, spec: PerformanceChartSpec, expect(state, first_raster.error == CpuRasterError.none && second_raster.error == CpuRasterError.none && first_raster.pixels_touched > 0 && - first_hash == 1813314353 && first_hash == second_hash, + first_hash == 426609686 && first_hash == second_hash, "CPU oracle renders the chart pixel-identically across two builds"); _zag_print("performance chart evidence hash: "); _zag_print_i64(first.*.evidence_hash); _zag_println(""); diff --git a/tests/scroll_viewport_contract.zag b/tests/scroll_viewport_contract.zag new file mode 100644 index 0000000..49dc5c1 --- /dev/null +++ b/tests/scroll_viewport_contract.zag @@ -0,0 +1,664 @@ +@import("../src/components/scroll_viewport.zag") +@import("../src/automation/talkback.zag") + +struct TestState { passed: i32, failed: i32 } + +struct ScrollViewportFixture { + display: DisplayList, + semantics: SemanticsTree, + hits: HitTree, +} + +fn expect(state: *TestState, + condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn scroll_viewport_fixture(include_resource: i32) + ScrollViewportFixture { + let fixture: ScrollViewportFixture = ScrollViewportFixture{ + .display = display_list_make(), + .semantics = semantics_tree_make(), + .hits = hit_tree_make(), + }; + if (include_resource != 0) { + _ = display_list_add_resource(&fixture.display, + render_resource_spec(700001, RenderResourceKind.svg_source, + "", 1)); + } + let first: DisplayOp = display_op(DisplayOpKind.fill_rect, + node_key(200)); + first.bounds = rect(0, 0, 480 * unit_scale(), 600 * unit_scale()); + first.paint = semantic_color_paint(SemanticColorToken.surface_panel); + _ = display_list_push(&fixture.display, first); + let second: DisplayOp = display_op(DisplayOpKind.fill_rect, + node_key(201)); + second.bounds = rect(100 * unit_scale(), 140 * unit_scale(), + 80 * unit_scale(), 50 * unit_scale()); + second.paint = semantic_color_paint(SemanticColorToken.accent); + _ = display_list_push(&fixture.display, second); + let third: DisplayOp = display_op(DisplayOpKind.fill_rect, + node_key(202)); + third.bounds = rect(420 * unit_scale(), 520 * unit_scale(), + 40 * unit_scale(), 40 * unit_scale()); + third.paint = semantic_color_paint(SemanticColorToken.status_error); + _ = display_list_push(&fixture.display, third); + _ = display_list_seal(&fixture.display); + + let root: SemanticsSpec = semantics_spec(node_key(200), + semantic_root_key(), SemanticRole.group, "Content document"); + root.has_bounds = 1; + root.bounds = rect(0, 0, 480 * unit_scale(), 600 * unit_scale()); + _ = semantics_add(&fixture.semantics, root); + let visible: SemanticsSpec = semantics_spec(node_key(201), + node_key(200), SemanticRole.button, "Visible content action"); + visible.description = "Hosted content target"; + visible.value = "Ready"; + visible.has_bounds = 1; + visible.bounds = second.bounds; + visible.focus_order = 1; + visible.action_mask = semantic_actions_add( + semantic_actions(SemanticAction.activate), SemanticAction.focus); + _ = semantics_add(&fixture.semantics, visible); + let offscreen: SemanticsSpec = semantics_spec(node_key(202), + node_key(200), SemanticRole.button, "Offscreen content action"); + offscreen.has_bounds = 1; + offscreen.bounds = third.bounds; + offscreen.focus_order = 2; + offscreen.action_mask = semantic_actions_add( + semantic_actions(SemanticAction.activate), SemanticAction.focus); + _ = semantics_add(&fixture.semantics, offscreen); + + let hit_root: HitNode = hit_node(node_key(200), hit_root_key(), + root.bounds); + hit_root.enabled = 0; + _ = hit_tree_add(&fixture.hits, hit_root); + let visible_hit: HitNode = hit_node(node_key(201), node_key(200), + second.bounds); + visible_hit.focusable = 1; + _ = hit_tree_add(&fixture.hits, visible_hit); + let offscreen_hit: HitNode = hit_node(node_key(202), node_key(200), + third.bounds); + offscreen_hit.focusable = 1; + _ = hit_tree_add(&fixture.hits, offscreen_hit); + return fixture; +} + +fn scroll_viewport_fixture_free( + fixture: *ScrollViewportFixture) void { + hit_tree_free(&fixture.*.hits); + semantics_tree_free(&fixture.*.semantics); + display_list_free(&fixture.*.display); +} + +fn scroll_viewport_destination() ScrollViewportFixture { + return ScrollViewportFixture{ + .display = display_list_make(), + .semantics = semantics_tree_make(), + .hits = hit_tree_make(), + }; +} + +fn hosted_two_axis_contract(state: *TestState) void { + let content: ScrollViewportFixture = scroll_viewport_fixture(1); + let destination: ScrollViewportFixture = scroll_viewport_destination(); + let scroll: ScrollState = scroll_state( + size(240 * unit_scale(), 160 * unit_scale()), + size(480 * unit_scale(), 600 * unit_scale())); + _ = scroll_to(&scroll, 80 * unit_scale(), 120 * unit_scale()); + let spec: ScrollViewportSpec = scroll_viewport_spec(node_key(100), + semantic_root_key(), hit_root_key(), + rect(20 * unit_scale(), 30 * unit_scale(), + 240 * unit_scale(), 160 * unit_scale()), + "Scrollable component gallery"); + spec.description = "Two-axis retained viewport"; + spec.value = "Vertical 120 of 440; horizontal 80 of 240"; + spec.axis = ScrollViewportAxis.both; + spec.focus_order = 5; + spec.content_focus_order_offset = 20; + spec.z_order = 10; + let content_hash: i64 = content.display.content_hash; + let result: ScrollViewportBuildResult = scroll_viewport_contribute( + spec, scroll, &content.display, &content.semantics, &content.hits, + &destination.display, &destination.semantics, &destination.hits); + + expect(state, result.error == ScrollViewportBuildError.none && + result.artifact.scrollable == 1 && + result.artifact.geometry.horizontal_track_visible == 1 && + result.artifact.geometry.vertical_track_visible == 1 && + result.artifact.geometry.visible_content_bounds.x == + 80 * unit_scale() && + result.artifact.geometry.visible_content_bounds.y == + 120 * unit_scale() && + result.artifact.geometry.content_transform.translate_x == + (20 - 80) * unit_scale() && + result.artifact.geometry.content_transform.translate_y == + (30 - 120) * unit_scale(), + "ScrollState drives exact two-axis clip translation and scrollbar geometry"); + expect(state, destination.display.ops.len == 11 && + destination.display.ops.data[0].kind == DisplayOpKind.save && + destination.display.ops.data[1].kind == DisplayOpKind.clip_rect && + destination.display.ops.data[2].kind == + DisplayOpKind.concat_transform && + destination.display.ops.data[3].kind == DisplayOpKind.fill_rect && + destination.display.ops.data[4].kind == DisplayOpKind.fill_rect && + destination.display.ops.data[5].kind == DisplayOpKind.fill_rect && + destination.display.ops.data[6].kind == DisplayOpKind.restore && + destination.display.ops.data[7].kind == + DisplayOpKind.fill_rounded_rect && + destination.display.ops.data[10].kind == + DisplayOpKind.fill_rounded_rect && + destination.display.ops.data[1].bounds.x == spec.bounds.x && + destination.display.ops.data[2].transform.translate_y == + result.artifact.geometry.content_transform.translate_y, + "display order is save clip transform content restore then tokenized track and thumb chrome"); + expect(state, result.artifact.track_token == + SemanticColorToken.surface_inset && + result.artifact.thumb_token == SemanticColorToken.border_emphasis && + destination.display.ops.data[7].paint.red == + semantic_color_paint(SemanticColorToken.surface_inset).red && + destination.display.ops.data[8].paint.blue == + semantic_color_paint(SemanticColorToken.border_emphasis).blue, + "scrollbar chrome exposes named semantic color provenance without local RGBA"); + + let root_semantic_index: i32 = semantics_find_index( + destination.semantics, spec.id); + let child_semantic_index: i32 = semantics_find_index( + destination.semantics, node_key(201)); + let offscreen_semantic_index: i32 = semantics_find_index( + destination.semantics, node_key(202)); + let root_semantic: SemanticsNode = + destination.semantics.nodes.data[root_semantic_index]; + let child_semantic: SemanticsNode = + destination.semantics.nodes.data[child_semantic_index]; + let offscreen_semantic: SemanticsNode = + destination.semantics.nodes.data[offscreen_semantic_index]; + expect(state, root_semantic.has_bounds == 1 && + root_semantic.bounds.x == spec.bounds.x && + root_semantic.bounds.width == spec.bounds.width && + root_semantic.has_range == 1 && + root_semantic.range_min == 0 && + root_semantic.range_max == 440 * unit_scale() && + root_semantic.range_value == 120 * unit_scale() && + semantic_actions_has(root_semantic.action_mask, + SemanticAction.scroll) != 0 && + semantic_actions_has(root_semantic.action_mask, + SemanticAction.focus) != 0, + "viewport semantics expose exact bounds range value and scroll/focus actions"); + expect(state, node_key_equal(child_semantic.parent, spec.id) != 0 && + child_semantic.bounds.x == 40 * unit_scale() && + child_semantic.bounds.y == 50 * unit_scale() && + child_semantic.focus_order == 21 && + offscreen_semantic.hidden == 0 && + offscreen_semantic.bounds.x == 360 * unit_scale() && + offscreen_semantic.bounds.y == 430 * unit_scale() && + offscreen_semantic.bounds.width == 40 * unit_scale() && + offscreen_semantic.bounds.height == 40 * unit_scale(), + "hosted semantics clip visible bounds but retain offscreen stable IDs and transformed geometry"); + + let routed: HitResult = hit_test(destination.hits, + 50 * unit_scale(), 60 * unit_scale()); + let offscreen_hit: HitResult = hit_test(destination.hits, + 460 * unit_scale(), 470 * unit_scale()); + let child_hit_index: i32 = hit_tree_find_index(destination.hits, + node_key(201)); + expect(state, routed.found != 0 && routed.id.value == 201 && + offscreen_hit.found == 0 && + destination.hits.nodes.data[child_hit_index].clip.x == + 80 * unit_scale() && + destination.hits.nodes.data[child_hit_index].clip.y == + 120 * unit_scale() && + destination.hits.nodes.data[child_hit_index].transform.translate_x == + result.artifact.geometry.content_transform.translate_x, + "hit routing uses the same content transform and visible clip as pixels"); + + let session: TalkbackSession = talkback_session_make( + talkback_capabilities()); + let viewport: Rect = rect(0, 0, 800 * unit_scale(), + 600 * unit_scale()); + let query: TalkbackResponse = talkback_dispatch(&session, + destination.semantics, viewport, + talkback_request(1, TalkbackCommand.query, spec.id, + destination.semantics.revision)); + let scroll_action: TalkbackResponse = talkback_dispatch(&session, + destination.semantics, viewport, + talkback_scroll_request(2, spec.id, + destination.semantics.revision, + TalkbackScrollAxis.vertical, 0, 48 * unit_scale())); + let fallback: TalkbackPixelFallbackTarget = + talkback_pixel_fallback_for_id(destination.semantics, + viewport, spec.id, TalkbackCommand.scroll, 2, 1); + let offscreen_query: TalkbackResponse = talkback_dispatch(&session, + destination.semantics, viewport, + talkback_request(3, TalkbackCommand.query, node_key(202), + destination.semantics.revision)); + let offscreen_pixel: TalkbackPixelFallbackTarget = + talkback_pixel_fallback_for_id(destination.semantics, + spec.bounds, node_key(202), TalkbackCommand.click, 1, 1); + expect(state, query.status == TalkbackStatus.accepted && + query.target_id.value == 100 && + query.bounds.x == spec.bounds.x && + scroll_action.status == TalkbackStatus.accepted && + scroll_action.emitted_action == 1 && + fallback.status == TalkbackStatus.accepted && + fallback.source_id.value == 100 && + fallback.logical_bounds.width == spec.bounds.width && + offscreen_query.status == TalkbackStatus.accepted && + offscreen_query.target_id.value == 202 && + offscreen_pixel.status == TalkbackStatus.pixel_out_of_bounds, + "Talkback queries and scrolls the exact stable viewport ID before pixel fallback"); + let horizontal_range_index: i32 = semantics_find_index( + destination.semantics, + scroll_viewport_horizontal_range_id(spec.id)); + let vertical_range_index: i32 = semantics_find_index( + destination.semantics, + scroll_viewport_vertical_range_id(spec.id)); + expect(state, horizontal_range_index >= 0 && vertical_range_index >= 0 && + destination.semantics.nodes.data[horizontal_range_index].role == + SemanticRole.slider && + destination.semantics.nodes.data[horizontal_range_index].range_max == + 240 * unit_scale() && + destination.semantics.nodes.data[horizontal_range_index].range_value == + 80 * unit_scale() && + destination.semantics.nodes.data[vertical_range_index].range_max == + 440 * unit_scale() && + destination.semantics.nodes.data[vertical_range_index].range_value == + 120 * unit_scale(), + "stable axis semantic IDs expose both exact ranges in a two-axis viewport"); + + let revealed_scroll: ScrollState = scroll; + let revealed: ScrollViewportTargetReveal = + scroll_viewport_reveal_hosted_semantic_id(&revealed_scroll, + spec.axis, spec.direction, spec.enabled, + &destination.semantics, result.artifact.geometry, + spec.id, node_key(202)); + expect(state, revealed.found == 1 && revealed.focusable == 1 && + revealed.transition.accepted == 1 && + revealed.content_bounds.x == 420 * unit_scale() && + revealed.content_bounds.y == 520 * unit_scale() && + revealed_scroll.offset_x == 220 * unit_scale() && + revealed_scroll.offset_y == 400 * unit_scale() && + revealed.evidence_hash != 0, + "hosted offscreen Talkback ID reverses exact geometry and reveals without retaining source content or guessing pixels"); + let revealed_destination: ScrollViewportFixture = + scroll_viewport_destination(); + let revealed_build: ScrollViewportBuildResult = + scroll_viewport_contribute(spec, revealed_scroll, + &content.display, &content.semantics, &content.hits, + &revealed_destination.display, + &revealed_destination.semantics, &revealed_destination.hits); + let revealed_semantic: SemanticsNode = + revealed_destination.semantics.nodes.data[ + semantics_find_index(revealed_destination.semantics, + node_key(202))]; + let revealed_hit: HitResult = hit_test(revealed_destination.hits, + 230 * unit_scale(), 160 * unit_scale()); + expect(state, revealed_build.error == ScrollViewportBuildError.none && + revealed_semantic.hidden == 0 && + revealed_semantic.bounds.x == 220 * unit_scale() && + revealed_semantic.bounds.y == 150 * unit_scale() && + revealed_hit.found != 0 && revealed_hit.id.value == 202, + "rebuild after semantic reveal makes the same ID visibly and pointer actionable"); + expect(state, content.display.sealed == 1 && + content.display.content_hash == content_hash && + result.artifact.evidence_hash != 0 && + result.artifact.display_resources_added == 1 && + display_list_seal(&destination.display) == DisplayError.none && + display_list_verify(&destination.display) == DisplayError.none, + "hosting preserves source ownership and seals deterministic destination evidence"); + + talkback_session_free(&session); + scroll_viewport_fixture_free(&revealed_destination); + scroll_viewport_fixture_free(&destination); + scroll_viewport_fixture_free(&content); +} + +fn rtl_and_mutation_contract(state: *TestState) void { + let scroll: ScrollState = scroll_state( + size(200 * unit_scale(), 100 * unit_scale()), + size(500 * unit_scale(), 100 * unit_scale())); + _ = scroll_to(&scroll, 60 * unit_scale(), 0); + let spec: ScrollViewportSpec = scroll_viewport_spec(node_key(300), + semantic_root_key(), hit_root_key(), + rect(10 * unit_scale(), 20 * unit_scale(), + 200 * unit_scale(), 100 * unit_scale()), + "RTL horizontal strip"); + spec.axis = ScrollViewportAxis.horizontal; + spec.direction = LayoutDirection.rtl; + let geometry: ScrollViewportGeometry = scroll_viewport_geometry(spec, + scroll); + // RTL thumb position proof at 60/300 logical offset (80% scrolled): + // physical_value = max - offset = 300 - 60 = 240 + // thumb_offset = travel * 240 / 300 = travel * 0.8 + // thumb_width = track_width * 200 / 500 = track_width * 0.4 + // thumb.center = track.x + travel*0.8 + thumb_width/2 + // track.center = track.x + track_width/2 + // thumb.center > track.center iff 0.3*track_width > 0.3*thumb_width + // Since thumb_width = 0.4*track_width, this holds for any track_width > 0. + // The original assertion (thumb.x > track.center) required + // track/thumb > 8/3 = 2.67, but the actual ratio is 1/0.4 = 2.5, + // making it false for this fixture. Comparing centers is the + // correct invariant: at 80% scroll the thumb center passes the + // track center, regardless of thumb proportions. + expect(state, geometry.valid == 1 && + geometry.visible_content_bounds.x == 240 * unit_scale() && + geometry.content_transform.translate_x == + (10 - 240) * unit_scale() && + geometry.horizontal_track_visible == 1 && + geometry.vertical_track_visible == 0 && + geometry.horizontal_thumb_bounds.x + + geometry.horizontal_thumb_bounds.width / 2 > + geometry.horizontal_track_bounds.x + + geometry.horizontal_track_bounds.width / 2, + "RTL mirrors physical visible origin and scrollbar thumb while retaining logical offset"); + + let wheel: ScrollViewportTransition = scroll_viewport_wheel(&scroll, + ScrollViewportAxis.horizontal, LayoutDirection.rtl, 1, + 40 * unit_scale(), 0); + expect(state, wheel.accepted == 1 && wheel.rejected == 0 && + wheel.requested_x == 0 - 40 * unit_scale() && + scroll.offset_x == 20 * unit_scale() && + wheel.mutation.consumed_x == 0 - 40 * unit_scale() && + wheel.evidence_hash != 0, + "physical horizontal wheel input maps to logical RTL ScrollState mutation"); + let right: ScrollViewportTransition = scroll_viewport_key(&scroll, + ScrollViewportAxis.horizontal, LayoutDirection.rtl, 1, + FlexDensity.standard, ScrollViewportCommand.arrow_right); + expect(state, right.accepted == 1 && scroll.offset_x == 0 && + right.mutation.unconsumed_x == 0 - 28 * unit_scale(), + "RTL right-arrow consumes toward logical start and reports boundary handoff"); + let reveal: ScrollViewportTransition = scroll_viewport_reveal_focus( + &scroll, ScrollViewportAxis.horizontal, LayoutDirection.rtl, 1, + rect(20 * unit_scale(), 0, 20 * unit_scale(), 40 * unit_scale())); + expect(state, reveal.accepted == 1 && + scroll.offset_x == 280 * unit_scale() && + scroll_visible_rect(scroll, LayoutDirection.rtl).x == + 20 * unit_scale(), + "focus reveal converts physical RTL target geometry back to logical offset authority"); +} + +fn keyboard_semantic_and_disabled_contract( + state: *TestState) void { + let scroll: ScrollState = scroll_state( + size(200 * unit_scale(), 160 * unit_scale()), + size(200 * unit_scale(), 500 * unit_scale())); + let page: ScrollViewportTransition = scroll_viewport_key(&scroll, + ScrollViewportAxis.vertical, LayoutDirection.ltr, 1, + FlexDensity.standard, ScrollViewportCommand.page_down); + expect(state, page.accepted == 1 && + scroll.offset_y == 112 * unit_scale() && + page.mutation.changed == 1, + "keyboard PageDown uses a density token step against ScrollState viewport extent"); + let end: ScrollViewportTransition = scroll_viewport_semantic_scroll( + &scroll, ScrollViewportAxis.vertical, LayoutDirection.ltr, 1, + FlexDensity.standard, ScrollViewportCommand.end); + expect(state, end.accepted == 1 && + scroll.offset_y == 340 * unit_scale() && + end.input_kind == ScrollViewportInputKind.semantic, + "semantic End routes through the same exact state mutation contract as keyboard input"); + let disabled_offset: i64 = scroll.offset_y; + let disabled: ScrollViewportTransition = scroll_viewport_wheel(&scroll, + ScrollViewportAxis.vertical, LayoutDirection.ltr, 0, + 0, 20 * unit_scale()); + expect(state, disabled.accepted == 0 && disabled.rejected == 1 && + disabled.rejected_disabled == 1 && + scroll.offset_y == disabled_offset && + scroll.revision == 2, + "disabled viewport rejects wheel input without phantom offset or revision"); + + let idle: ScrollState = scroll_state( + size(200 * unit_scale(), 160 * unit_scale()), + size(160 * unit_scale(), 100 * unit_scale())); + let no_overflow: ScrollViewportTransition = + scroll_viewport_semantic_scroll(&idle, + ScrollViewportAxis.vertical, LayoutDirection.ltr, 1, + FlexDensity.standard, ScrollViewportCommand.page_down); + expect(state, no_overflow.accepted == 0 && + no_overflow.rejected_no_overflow == 1 && + idle.offset_y == 0 && idle.revision == 0, + "no-overflow semantic scroll fails closed without fabricating work"); + + let reveal_state: ScrollState = scroll_state( + size(200 * unit_scale(), 160 * unit_scale()), + size(200 * unit_scale(), 500 * unit_scale())); + let reveal: ScrollViewportTransition = scroll_viewport_reveal_focus( + &reveal_state, ScrollViewportAxis.vertical, LayoutDirection.ltr, 1, + rect(20 * unit_scale(), 350 * unit_scale(), + 80 * unit_scale(), 20 * unit_scale())); + expect(state, reveal.accepted == 1 && + reveal_state.offset_y == 210 * unit_scale(), + "focus reveal scrolls only enough to expose the complete focused target"); +} + +fn no_overflow_disabled_and_focus_contract( + state: *TestState) void { + let content: ScrollViewportFixture = scroll_viewport_fixture(0); + let destination: ScrollViewportFixture = scroll_viewport_destination(); + let scroll: ScrollState = scroll_state( + size(520 * unit_scale(), 640 * unit_scale()), + size(480 * unit_scale(), 600 * unit_scale())); + let spec: ScrollViewportSpec = scroll_viewport_spec(node_key(400), + semantic_root_key(), hit_root_key(), + rect(0, 0, 520 * unit_scale(), 640 * unit_scale()), + "Static content viewport"); + spec.focus_order = 10; + spec.focused = 1; + spec.focus_visible = 1; + spec.content_focus_order_offset = 20; + let built: ScrollViewportBuildResult = scroll_viewport_contribute( + spec, scroll, &content.display, &content.semantics, &content.hits, + &destination.display, &destination.semantics, &destination.hits); + let root: SemanticsNode = destination.semantics.nodes.data[ + semantics_find_index(destination.semantics, spec.id)]; + expect(state, built.error == ScrollViewportBuildError.none && + built.artifact.scrollable == 0 && + built.artifact.focus_ring_visible == 1 && + built.artifact.geometry.horizontal_track_visible == 0 && + built.artifact.geometry.vertical_track_visible == 0 && + built.artifact.display_operations_added == 8 && + destination.display.ops.data[7].kind == DisplayOpKind.stroke_rect && + destination.display.ops.data[7].paint.blue == + semantic_color_paint(SemanticColorToken.focus).blue && + root.has_range == 0 && + semantic_actions_has(root.action_mask, SemanticAction.scroll) == 0 && + semantic_actions_has(root.action_mask, SemanticAction.focus) != 0 && + root.focused == 1, + "no-overflow viewport removes fake scroll truth while retaining real semantic focus and ring"); + scroll_viewport_fixture_free(&destination); + scroll_viewport_fixture_free(&content); + + let disabled_content: ScrollViewportFixture = + scroll_viewport_fixture(0); + let disabled_destination: ScrollViewportFixture = + scroll_viewport_destination(); + let disabled_scroll: ScrollState = scroll_state( + size(240 * unit_scale(), 160 * unit_scale()), + size(480 * unit_scale(), 600 * unit_scale())); + let disabled_spec: ScrollViewportSpec = scroll_viewport_spec(node_key(401), + semantic_root_key(), hit_root_key(), + rect(0, 0, 240 * unit_scale(), 160 * unit_scale()), + "Disabled viewport"); + disabled_spec.axis = ScrollViewportAxis.both; + disabled_spec.enabled = 0; + disabled_spec.focus_order = 12; + disabled_spec.content_focus_order_offset = 20; + let disabled_built: ScrollViewportBuildResult = + scroll_viewport_contribute(disabled_spec, disabled_scroll, + &disabled_content.display, &disabled_content.semantics, + &disabled_content.hits, &disabled_destination.display, + &disabled_destination.semantics, &disabled_destination.hits); + let disabled_root: SemanticsNode = + disabled_destination.semantics.nodes.data[ + semantics_find_index(disabled_destination.semantics, + disabled_spec.id)]; + let disabled_hit: HitNode = disabled_destination.hits.nodes.data[ + hit_tree_find_index(disabled_destination.hits, disabled_spec.id)]; + expect(state, disabled_built.error == ScrollViewportBuildError.none && + disabled_root.disabled == 1 && disabled_root.action_mask == 0 && + disabled_root.focused == 0 && disabled_hit.enabled == 0 && + disabled_hit.focusable == 0 && + disabled_destination.semantics.nodes.data[ + semantics_find_index(disabled_destination.semantics, + node_key(201))].action_mask == 0 && + disabled_destination.hits.nodes.data[ + hit_tree_find_index(disabled_destination.hits, + node_key(201))].enabled == 0, + "disabled state preserves inspectable IDs while removing root and child actionability"); + scroll_viewport_fixture_free(&disabled_destination); + scroll_viewport_fixture_free(&disabled_content); +} + +fn atomic_failure_contract(state: *TestState) void { + let content: ScrollViewportFixture = scroll_viewport_fixture(1); + let destination: ScrollViewportFixture = scroll_viewport_destination(); + _ = display_list_add_resource(&destination.display, + render_resource_spec(700001, RenderResourceKind.svg_source, + "", 1)); + let background: DisplayOp = display_op(DisplayOpKind.fill_rect, + node_key(900)); + background.bounds = rect(0, 0, 20 * unit_scale(), 20 * unit_scale()); + background.paint = semantic_color_paint(SemanticColorToken.canvas); + _ = display_list_push(&destination.display, background); + let scroll: ScrollState = scroll_state( + size(240 * unit_scale(), 160 * unit_scale()), + size(480 * unit_scale(), 600 * unit_scale())); + let spec: ScrollViewportSpec = scroll_viewport_spec(node_key(500), + semantic_root_key(), hit_root_key(), + rect(0, 0, 240 * unit_scale(), 160 * unit_scale()), + "Atomic viewport"); + spec.axis = ScrollViewportAxis.both; + spec.content_focus_order_offset = 20; + let ops_before: i32 = destination.display.ops.len; + let resources_before: i32 = destination.display.resources.resources.len; + let display_hash_before: i64 = destination.display.content_hash; + let collision: ScrollViewportBuildResult = scroll_viewport_contribute( + spec, scroll, &content.display, &content.semantics, &content.hits, + &destination.display, &destination.semantics, &destination.hits); + expect(state, collision.error == + ScrollViewportBuildError.resource_collision && + collision.resource_error == RenderResourceError.duplicate_id && + destination.display.ops.len == ops_before && + destination.display.resources.resources.len == resources_before && + destination.display.content_hash == display_hash_before && + destination.semantics.nodes.len == 0 && + destination.hits.nodes.len == 0, + "resource collision leaves display resources semantics and hits byte-for-byte unmodified"); + scroll_viewport_fixture_free(&destination); + scroll_viewport_fixture_free(&content); + + let semantic_content: ScrollViewportFixture = + scroll_viewport_fixture(0); + let semantic_destination: ScrollViewportFixture = + scroll_viewport_destination(); + semantic_content.semantics.nodes.data[1].parent = node_key(999999); + let semantic_failed: ScrollViewportBuildResult = + scroll_viewport_contribute(spec, scroll, + &semantic_content.display, &semantic_content.semantics, + &semantic_content.hits, &semantic_destination.display, + &semantic_destination.semantics, &semantic_destination.hits); + expect(state, semantic_failed.error == + ScrollViewportBuildError.semantics_error && + semantic_failed.semantics_error == SemanticsError.missing_parent && + semantic_destination.display.ops.len == 0 && + semantic_destination.display.resources.resources.len == 0 && + semantic_destination.semantics.nodes.len == 0 && + semantic_destination.hits.nodes.len == 0, + "late semantic failure rolls back the entire staged host contribution"); + scroll_viewport_fixture_free(&semantic_destination); + scroll_viewport_fixture_free(&semantic_content); + + let hit_content: ScrollViewportFixture = scroll_viewport_fixture(0); + let hit_destination: ScrollViewportFixture = scroll_viewport_destination(); + hit_content.hits.nodes.data[1].parent = node_key(999999); + let hit_failed: ScrollViewportBuildResult = scroll_viewport_contribute( + spec, scroll, &hit_content.display, &hit_content.semantics, + &hit_content.hits, &hit_destination.display, + &hit_destination.semantics, &hit_destination.hits); + expect(state, hit_failed.error == ScrollViewportBuildError.hit_error && + hit_failed.hit_error == HitTreeError.missing_parent && + hit_destination.display.ops.len == 0 && + hit_destination.semantics.nodes.len == 0 && + hit_destination.hits.nodes.len == 0, + "late hit failure also restores semantics and discards staged display resources"); + scroll_viewport_fixture_free(&hit_destination); + scroll_viewport_fixture_free(&hit_content); + + let limited_content: ScrollViewportFixture = scroll_viewport_fixture(0); + let limited_destination: ScrollViewportFixture = + scroll_viewport_destination(); + let limited_spec: ScrollViewportSpec = spec; + limited_spec.limits.display_operations = 1; + let limited: ScrollViewportBuildResult = scroll_viewport_contribute( + limited_spec, scroll, &limited_content.display, + &limited_content.semantics, &limited_content.hits, + &limited_destination.display, &limited_destination.semantics, + &limited_destination.hits); + expect(state, limited.error == ScrollViewportBuildError.capacity_error && + limited.capacity_error == + ScrollViewportCapacityError.display_operations && + limited_destination.display.ops.len == 0 && + limited_destination.semantics.nodes.len == 0 && + limited_destination.hits.nodes.len == 0, + "explicit operation ceiling fails before any caller-owned builder mutation"); + scroll_viewport_fixture_free(&limited_destination); + scroll_viewport_fixture_free(&limited_content); +} + +fn deterministic_contract(state: *TestState) void { + let content: ScrollViewportFixture = scroll_viewport_fixture(0); + let first: ScrollViewportFixture = scroll_viewport_destination(); + let second: ScrollViewportFixture = scroll_viewport_destination(); + let scroll: ScrollState = scroll_state( + size(240 * unit_scale(), 160 * unit_scale()), + size(480 * unit_scale(), 600 * unit_scale())); + _ = scroll_to(&scroll, 30 * unit_scale(), 70 * unit_scale()); + let spec: ScrollViewportSpec = scroll_viewport_spec(node_key(600), + semantic_root_key(), hit_root_key(), + rect(4 * unit_scale(), 8 * unit_scale(), + 240 * unit_scale(), 160 * unit_scale()), + "Deterministic viewport"); + spec.axis = ScrollViewportAxis.both; + spec.content_focus_order_offset = 20; + let a: ScrollViewportBuildResult = scroll_viewport_contribute(spec, + scroll, &content.display, &content.semantics, &content.hits, + &first.display, &first.semantics, &first.hits); + let b: ScrollViewportBuildResult = scroll_viewport_contribute(spec, + scroll, &content.display, &content.semantics, &content.hits, + &second.display, &second.semantics, &second.hits); + expect(state, a.error == ScrollViewportBuildError.none && + b.error == ScrollViewportBuildError.none && + a.artifact.evidence_hash == b.artifact.evidence_hash && + a.artifact.geometry.evidence_hash == + b.artifact.geometry.evidence_hash && + first.display.content_hash == second.display.content_hash && + first.display.ops.len == second.display.ops.len && + first.semantics.nodes.len == second.semantics.nodes.len && + first.hits.nodes.len == second.hits.nodes.len, + "identical state content and host inputs produce identical retained evidence"); + scroll_viewport_fixture_free(&second); + scroll_viewport_fixture_free(&first); + scroll_viewport_fixture_free(&content); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + hosted_two_axis_contract(&state); + rtl_and_mutation_contract(&state); + keyboard_semantic_and_disabled_contract(&state); + no_overflow_disabled_and_focus_contract(&state); + atomic_failure_contract(&state); + deterministic_contract(&state); + let passed: []u8 = _zag_i64_to_str(state.passed as i64); + let failed: []u8 = _zag_i64_to_str(state.failed as i64); + _zag_print("ScrollViewport contract: pass="); _zag_print(passed); + _zag_print(" fail="); _zag_println(failed); + _zag_str_free(passed); _zag_str_free(failed); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tests/segmented_control_contract.zag b/tests/segmented_control_contract.zag new file mode 100644 index 0000000..3ba4223 --- /dev/null +++ b/tests/segmented_control_contract.zag @@ -0,0 +1,874 @@ +@import("../src/components/segmented_control.zag") +@import("../src/automation/talkback.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, condition: i32, + name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn option_label(index: i32) []u8 { + return switch (index) { + 0 => "Overview", 1 => "Components", 2 => "Typography", + 3 => "Motion", 4 => "Renderer", 5 => "Accessibility", + else => "Segment", + }; +} + +fn option_description(index: i32) []u8 { + return switch (index) { + 0 => "Show overview", 1 => "Show components", 2 => "Show type ramp", + 3 => "Show motion", 4 => "Show renderer", 5 => "Show accessibility", + else => "Show segment", + }; +} + +fn fill_options(options: *ArrayList[SegmentedControlOption], + base: i64, count: i32) void { + let index: i32 = 0; + while (index < count) { + push[SegmentedControlOption](options, SegmentedControlOption{ + .id = node_key(base + index as i64), + .label = option_label(index), + .description = option_description(index), + }); + index = index + 1; + } +} + +fn copy_owned_bytes(destination: *ArrayList[u8], + source: []u8) void { + let index: i32 = 0; + while (index < source.len) { + push[u8](destination, source[index]); + index = index + 1; + } +} + +fn six_state_spec(group_id: i64) SegmentedControlSpec { + let model: SegmentedControlModel = segmented_control_model(6, 5); + model.hovered_index = 1; + model.roving_index = 2; + model.has_focus = 1; + model.focus_visible = 1; + model.pressed_index = 3; + model.enabled_mask = model.enabled_mask & + (0 - 1 - segmented_control_bit(4)); + let spec: SegmentedControlSpec = segmented_control_spec(node_key(group_id), + hit_root_key(), rect(40 * unit_scale(), 60 * unit_scale(), + 600 * unit_scale(), 48 * unit_scale()), + "Toolkit sections", model); + spec.description = "Choose exactly one toolkit section"; + spec.focus_order_base = 20; + spec.z_order = 30; + return spec; +} + +fn model_and_reducer_contract(state: *TestState) void { + let model: SegmentedControlModel = segmented_control_model(4, 1); + expect(state, segmented_control_model_valid(model) != 0 && + model.enabled_mask == 15 && model.selected_index == 1 && + model.roving_index == 1 && model.has_focus == 0 && + model.focus_visible == 0, + "model constructor establishes one selection and roving target without fabricating focus"); + + let entered: SegmentedControlTransition = segmented_control_reduce(model, + segmented_control_event(SegmentedControlEventKind.pointer_enter, 2)); + let down: SegmentedControlTransition = segmented_control_reduce(entered.model, + segmented_control_event(SegmentedControlEventKind.pointer_down, 2)); + let up: SegmentedControlTransition = segmented_control_reduce(down.model, + segmented_control_event(SegmentedControlEventKind.pointer_up_inside, 2)); + expect(state, entered.model.hovered_index == 2 && + down.model.pressed_index == 2 && down.model.roving_index == 2 && + down.model.has_focus == 1 && down.model.focus_visible == 0 && + down.roving_changed == 1 && down.focus_changed == 1 && + up.model.pressed_index == segmented_control_no_index() && + up.model.selected_index == 2 && up.selection_changed == 1 && + up.previous_selected_index == 1 && up.activated == 1, + "pointer focus stays non-visible while inside release emits one explicit selection action"); + + let cancelled: SegmentedControlTransition = segmented_control_reduce(down.model, + segmented_control_event(SegmentedControlEventKind.pointer_cancel, + segmented_control_no_index())); + let mismatched: SegmentedControlTransition = segmented_control_reduce(down.model, + segmented_control_event(SegmentedControlEventKind.pointer_up_inside, 3)); + expect(state, cancelled.model.pressed_index == segmented_control_no_index() && + cancelled.model.selected_index == 1 && cancelled.activated == 0 && + mismatched.rejected == 1 && mismatched.selection_changed == 0, + "cancel and mismatched releases never manufacture a selection"); + + let focus: SegmentedControlTransition = segmented_control_reduce(model, + segmented_control_event(SegmentedControlEventKind.focus_target, 3)); + let blur: SegmentedControlTransition = segmented_control_reduce(focus.model, + segmented_control_event(SegmentedControlEventKind.focus_lost, 3)); + expect(state, focus.model.roving_index == 3 && + focus.model.has_focus == 1 && focus.model.focus_visible == 1 && + focus.roving_changed == 1 && focus.focus_changed == 1 && + focus.model.selected_index == 1 && focus.selection_changed == 0 && + blur.model.roving_index == 3 && blur.model.has_focus == 0 && + blur.model.focus_visible == 0 && blur.focus_changed == 1, + "focus ownership and visibility change without destroying the roving target or selection"); + + let available: SegmentedControlModel = model; + available.enabled_mask = available.enabled_mask & + (0 - 1 - segmented_control_bit(2)); + let next: SegmentedControlTransition = segmented_control_reduce(available, + segmented_control_event(SegmentedControlEventKind.key_next, + segmented_control_no_index())); + let previous: SegmentedControlTransition = segmented_control_reduce(next.model, + segmented_control_event(SegmentedControlEventKind.key_previous, + segmented_control_no_index())); + expect(state, next.model.selected_index == 3 && next.model.roving_index == 3 && + next.model.has_focus == 1 && next.model.focus_visible == 1 && + next.roving_changed == 1 && next.focus_changed == 1 && + next.selection_changed == 1 && next.activated == 1 && + previous.model.selected_index == 1, + "arrow-key selection starts from selection when unfocused, skips disabled segments, and exposes keyboard focus"); + + let home: SegmentedControlTransition = segmented_control_reduce(available, + segmented_control_event(SegmentedControlEventKind.key_home, + segmented_control_no_index())); + let end: SegmentedControlTransition = segmented_control_reduce(available, + segmented_control_event(SegmentedControlEventKind.key_end, + segmented_control_no_index())); + expect(state, home.model.selected_index == 0 && + end.model.selected_index == 3 && home.selection_changed == 1 && + end.selection_changed == 1, + "Home and End select the first and last available segment"); + + let direct: SegmentedControlTransition = segmented_control_reduce(model, + segmented_control_event(SegmentedControlEventKind.select_target, 3)); + let activate: SegmentedControlTransition = segmented_control_reduce(model, + segmented_control_event(SegmentedControlEventKind.activate_focused, + segmented_control_no_index())); + expect(state, direct.model.selected_index == 3 && direct.activated == 1 && + direct.model.has_focus == 0 && direct.model.focus_visible == 0 && + direct.roving_changed == 1 && direct.focus_changed == 0 && + activate.model.selected_index == 1 && activate.selection_changed == 0 && + activate.model.has_focus == 1 && activate.model.focus_visible == 1 && + activate.roving_changed == 0 && activate.focus_changed == 1 && + activate.activated == 1, + "semantic selection preserves focus mode while keyboard activation establishes visible focus"); + + let disabled_current: SegmentedControlTransition = segmented_control_reduce(model, + segmented_control_event(SegmentedControlEventKind.disable_target, 1)); + expect(state, segmented_control_index_enabled(disabled_current.model, 1) == 0 && + disabled_current.model.selected_index == 2 && + disabled_current.selection_changed == 1 && + segmented_control_model_valid(disabled_current.model) != 0, + "disabling the selection atomically chooses the next enabled segment"); + + let only_one: SegmentedControlModel = segmented_control_model(2, 0); + only_one.enabled_mask = segmented_control_bit(0); + let refuse_last: SegmentedControlTransition = segmented_control_reduce(only_one, + segmented_control_event(SegmentedControlEventKind.disable_target, 0)); + let refuse_disabled: SegmentedControlTransition = segmented_control_reduce(available, + segmented_control_event(SegmentedControlEventKind.select_target, 2)); + expect(state, refuse_last.rejected == 1 && refuse_last.model.enabled_mask == 1 && + refuse_disabled.rejected == 1 && refuse_disabled.model.selected_index == 1, + "the reducer rejects zero-selection and disabled-selection states"); + + let invalid_target: SegmentedControlTransition = segmented_control_reduce(model, + segmented_control_event(SegmentedControlEventKind.pointer_down, 99)); + let invalid_model: SegmentedControlModel = model; + invalid_model.selected_index = 99; + let invalid_state: SegmentedControlTransition = segmented_control_reduce(invalid_model, + segmented_control_event(SegmentedControlEventKind.key_next, + segmented_control_no_index())); + let impossible_focus: SegmentedControlModel = model; + impossible_focus.focus_visible = 1; + expect(state, invalid_target.rejected == 1 && invalid_state.rejected == 1 && + segmented_control_model_valid(impossible_focus) == 0 && + invalid_target.model.selected_index == model.selected_index, + "invalid targets, invalid selections, and visible focus without ownership fail closed"); +} + +fn exhaustive_selection_property_contract( + state: *TestState) void { + let valid: i32 = 1; + let cases: i64 = 0; + let count: i32 = 2; + while (count <= segmented_control_maximum_segments()) { + let limit: i64 = (1 as i64) << count; + let mask: i64 = 1; + while (mask < limit) { + let selected: i32 = 0; + while (selected < count) { + if ((mask & segmented_control_bit(selected)) != 0) { + let model: SegmentedControlModel = segmented_control_model( + count, selected); + model.enabled_mask = mask; + model.roving_index = selected; + if (segmented_control_model_valid(model) == 0) { valid = 0; } + let next: SegmentedControlTransition = segmented_control_reduce( + model, segmented_control_event( + SegmentedControlEventKind.key_next, + segmented_control_no_index())); + let previous: SegmentedControlTransition = + segmented_control_reduce(model, segmented_control_event( + SegmentedControlEventKind.key_previous, + segmented_control_no_index())); + let home: SegmentedControlTransition = segmented_control_reduce( + model, segmented_control_event( + SegmentedControlEventKind.key_home, + segmented_control_no_index())); + let end: SegmentedControlTransition = segmented_control_reduce( + model, segmented_control_event( + SegmentedControlEventKind.key_end, + segmented_control_no_index())); + if (next.rejected != 0 || previous.rejected != 0 || + home.rejected != 0 || end.rejected != 0 || + segmented_control_model_valid(next.model) == 0 || + segmented_control_model_valid(previous.model) == 0 || + segmented_control_model_valid(home.model) == 0 || + segmented_control_model_valid(end.model) == 0 || + next.model.has_focus == 0 || next.model.focus_visible == 0 || + previous.model.has_focus == 0 || + previous.model.focus_visible == 0 || + home.model.has_focus == 0 || home.model.focus_visible == 0 || + end.model.has_focus == 0 || end.model.focus_visible == 0 || + segmented_control_index_enabled(next.model, + next.model.selected_index) == 0 || + segmented_control_index_enabled(previous.model, + previous.model.selected_index) == 0) { valid = 0; } + cases = cases + 4; + } + selected = selected + 1; + } + mask = mask + 1; + } + count = count + 1; + } + expect(state, valid != 0 && cases == 180224, + "all 180224 enabled-mask selection and directional-key cases preserve one valid selection"); +} + +fn placement_and_state_contract(state: *TestState) void { + let options: ArrayList[SegmentedControlOption] = make[SegmentedControlOption](6); + fill_options(&options, 100, 6); + let spec: SegmentedControlSpec = six_state_spec(90); + expect(state, segmented_control_spec_valid(spec, &options) != 0 && + segmented_control_minimum_target() == 44 * unit_scale() && + segmented_control_minimum_height(FlexDensity.standard) == 48 * unit_scale(), + "valid controls retain a 44-pixel target inside a token-spaced rail"); + + let first: Rect = segmented_control_segment_bounds(spec, 0); + let last: Rect = segmented_control_segment_bounds(spec, 5); + let gap: i64 = segmented_control_gap(spec.density); + let index: i32 = 0; + let exact_chain: i32 = 1; + while (index < 5) { + let left: Rect = segmented_control_segment_bounds(spec, index); + let right: Rect = segmented_control_segment_bounds(spec, index + 1); + if (left.x + left.width + gap != right.x) { exact_chain = 0; } + index = index + 1; + } + expect(state, exact_chain != 0 && + first.x == spec.bounds.x + segmented_control_padding(spec.density) && + last.x + last.width == spec.bounds.x + spec.bounds.width - + segmented_control_padding(spec.density), + "segment placement conserves every fixed-point unit and exact Flex gap"); + + let content: Rect = segmented_control_content_bounds(spec, 0); + expect(state, content.x == first.x + + flex_spacing(FlexSpacingToken.large, spec.density) && + content.y == first.y + flex_spacing(FlexSpacingToken.small, spec.density) && + content.width == first.width - + flex_spacing(FlexSpacingToken.large, spec.density) * 2, + "every label slot exposes exact Flex padding for first-party Text"); + + let rest: SegmentedControlResolvedStyle = segmented_control_style(spec, 0); + let hover: SegmentedControlResolvedStyle = segmented_control_style(spec, 1); + let focus: SegmentedControlResolvedStyle = segmented_control_style(spec, 2); + let pressed: SegmentedControlResolvedStyle = segmented_control_style(spec, 3); + let disabled: SegmentedControlResolvedStyle = segmented_control_style(spec, 4); + let selected: SegmentedControlResolvedStyle = segmented_control_style(spec, 5); + expect(state, rest.fill_token == SemanticColorToken.surface_base && + hover.fill_token == SemanticColorToken.surface_interactive && + focus.border_token == SemanticColorToken.focus && + pressed.fill_token == SemanticColorToken.accent_emphasis && + disabled.label_color_token == SemanticColorToken.text_disabled && + selected.fill_token == SemanticColorToken.surface_selected, + "default hover focus pressed disabled and selected map to named semantic tokens"); + expect(state, selected.marker_token == SemanticColorToken.accent && + selected.selected == 1 && focus.focused == 1 && + focus.has_focus == 1 && focus.focus_visible == 1 && + pressed.pressed == 1 && + disabled.disabled == 1 && hover.hovered == 1, + "all six state flags remain inspectable independently of resolved color"); + + let resting: SegmentedControlSpec = spec; + resting.model = segmented_control_model(6, 5); + let resting_selected: SegmentedControlResolvedStyle = + segmented_control_style(resting, 5); + expect(state, resting_selected.selected == 1 && + resting_selected.has_focus == 0 && + resting_selected.focus_visible == 0 && + resting_selected.focused == 0 && + resting_selected.border_token == SemanticColorToken.border_emphasis, + "resting selection keeps its non-color cue without painting an unearned focus ring"); + + let too_small: SegmentedControlSpec = spec; + too_small.bounds.width = segmented_control_minimum_width(spec.model, + spec.density) - 1; + expect(state, segmented_control_spec_valid(too_small, &options) == 0, + "undersized segmented targets fail instead of gaining invisible hit halos"); + let invalid_model: SegmentedControlSpec = spec; + invalid_model.model.selected_index = 4; + options.data[0].label = ""; + expect(state, segmented_control_spec_valid(invalid_model, &options) == 0 && + segmented_control_spec_valid(spec, &options) == 0, + "disabled selections and empty labels fail strict validation"); + options.data[0].label = option_label(0); + free[SegmentedControlOption](&options); + + let placement_valid: i32 = 1; + let count: i32 = 2; + while (count <= segmented_control_maximum_segments()) { + let placement_options: ArrayList[SegmentedControlOption] = + make[SegmentedControlOption](count); + fill_options(&placement_options, 1000 + count as i64 * 20, count); + let model: SegmentedControlModel = segmented_control_model(count, 0); + let extra: i32 = 0; + while (extra < count) { + let placement: SegmentedControlSpec = segmented_control_spec( + node_key(900 + count as i64), hit_root_key(), + rect(0, 0, + segmented_control_minimum_width(model, + FlexDensity.standard) + extra as i64, + segmented_control_minimum_height(FlexDensity.standard)), + "Placement property", model); + let child: i32 = 0; + let expected_x: i64 = placement.bounds.x + + segmented_control_padding(placement.density); + while (child < count) { + let bounds: Rect = segmented_control_segment_bounds( + placement, child); + if (bounds.x != expected_x || + bounds.width < segmented_control_minimum_target()) { + placement_valid = 0; + } + expected_x = bounds.x + bounds.width; + if (child + 1 < count) { + expected_x = expected_x + + segmented_control_gap(placement.density); + } + child = child + 1; + } + if (expected_x != placement.bounds.x + placement.bounds.width - + segmented_control_padding(placement.density)) { + placement_valid = 0; + } + extra = extra + 1; + } + free[SegmentedControlOption](&placement_options); + count = count + 1; + } + expect(state, placement_valid != 0, + "every supported count and fixed-point remainder conserves exact target geometry"); +} + +fn retained_tree_contract(state: *TestState) void { + let options: ArrayList[SegmentedControlOption] = make[SegmentedControlOption](6); + fill_options(&options, 100, 6); + let spec: SegmentedControlSpec = six_state_spec(90); + let display: DisplayList = display_list_make(); + let hits: HitTree = hit_tree_make(); + let semantics: SemanticsTree = semantics_tree_make(); + let built: SegmentedControlBuildResult = segmented_control_emit(spec, + &options, &display, &hits, &semantics); + expect(state, built.error == SegmentedControlBuildError.none && + built.artifact.id.value == 90 && built.artifact.segment_count == 6 && + built.artifact.selected_index == 5 && + built.artifact.selected_id.value == 105 && + built.artifact.roving_index == 2 && + built.artifact.has_focus == 1 && built.artifact.focus_visible == 1 && + built.artifact.selected_marker_visible == 1 && + built.artifact.rail_fill_token == SemanticColorToken.surface_inset && + built.artifact.rail_border_token == SemanticColorToken.border_subtle && + built.artifact.rail_radius_token == SemanticRadiusToken.control && + built.artifact.rail_elevation_token == SemanticElevationToken.base && + built.artifact.label_type_token == SemanticTypeToken.label && + built.artifact.group_role == SemanticRole.tab_list, + "successful emission returns exact single-selection artifact truth"); + expect(state, hits.nodes.len == 7 && semantics.nodes.len == 7 && + built.artifact.group_hit_node_index == 0 && + built.artifact.group_semantics_node_index == 0 && + semantics.nodes.data[0].role == SemanticRole.tab_list && + semantics.nodes.data[0].action_mask == 0, + "one non-focusable group owns six retained tab children"); + + let index: i32 = 0; + let shared: i32 = 1; + let roving_focus_targets: i32 = 0; + while (index < 6) { + let bounds: Rect = segmented_control_segment_bounds(spec, index); + let hit: HitNode = hits.nodes.data[index + 1]; + let semantic: SemanticsNode = semantics.nodes.data[index + 1]; + if (node_key_equal(hit.id, options.data[index].id) == 0 || + node_key_equal(semantic.id, options.data[index].id) == 0 || + hit.bounds.x != bounds.x || hit.bounds.y != bounds.y || + hit.bounds.width != bounds.width || hit.bounds.height != bounds.height || + semantic.bounds.x != bounds.x || semantic.bounds.y != bounds.y || + semantic.bounds.width != bounds.width || semantic.bounds.height != bounds.height || + semantic.role != SemanticRole.tab || + semantic.focus_order != + ((index == spec.model.roving_index) as i64) * + spec.focus_order_base || + semantic.focused != (index == spec.model.roving_index) as i32 || + semantic.set_size != 6 || semantic.position_in_set != index + 1) { + shared = 0; + } + if (hit.focusable != 0 || semantic.focus_order > 0) { + if (hit.focusable == 1 && semantic.focus_order == + spec.focus_order_base && index == spec.model.roving_index) { + roving_focus_targets = roving_focus_targets + 1; + } else { shared = 0; } + } + let exact_pixel_owner: i32 = 0; + let op_index: i32 = built.artifact.display_op_start; + while (op_index < built.artifact.display_op_end) { + let op: DisplayOp = display.ops.data[op_index]; + if (node_key_equal(op.owner, options.data[index].id) != 0 && + op.bounds.x == bounds.x && op.bounds.y == bounds.y && + op.bounds.width == bounds.width && op.bounds.height == bounds.height) { + exact_pixel_owner = 1; + } + op_index = op_index + 1; + } + if (exact_pixel_owner == 0) { shared = 0; } + index = index + 1; + } + expect(state, shared != 0 && roving_focus_targets == 1, + "pixels hit testing semantics and Talkback share exact child identity while exposing one roving tab stop"); + + expect(state, semantics.nodes.data[6].selected == 1 && + semantic_text_equal(semantics.nodes.data[6].value, "Selected") != 0 && + semantics.nodes.data[5].disabled == 1 && + semantics.nodes.data[5].action_mask == 0 && + semantic_text_equal(semantics.nodes.data[5].value, "Unavailable") != 0 && + hits.nodes.data[5].enabled == 0 && hits.nodes.data[5].focusable == 0, + "selected and disabled truth agrees across semantic value actions and hit routing"); + expect(state, semantic_actions_has(semantics.nodes.data[1].action_mask, + SemanticAction.activate) != 0 && + semantic_actions_has(semantics.nodes.data[1].action_mask, + SemanticAction.select) != 0 && + semantic_actions_has(semantics.nodes.data[1].action_mask, + SemanticAction.focus) != 0, + "enabled segments expose keyboard activation selection and focus actions"); + + let selected_bounds: Rect = segmented_control_segment_bounds(spec, 5); + let selected_hit: HitResult = hit_test(hits, + selected_bounds.x + selected_bounds.width / 2, + selected_bounds.y + selected_bounds.height / 2); + let disabled_bounds: Rect = segmented_control_segment_bounds(spec, 4); + let disabled_hit: HitResult = hit_test(hits, + disabled_bounds.x + disabled_bounds.width / 2, + disabled_bounds.y + disabled_bounds.height / 2); + expect(state, selected_hit.found != 0 && selected_hit.id.value == 105 && + disabled_hit.found == 0, + "pointer routing resolves enabled stable IDs while disabled pixels fail closed"); + + let selected_op_count: i32 = 0; + let selected_marker: i32 = 0; + let selected_shadow: i32 = 0; + let focus_ring_geometry: i32 = 0; + let pressed_inset_geometry: i32 = 0; + let focus_bounds: Rect = segmented_control_segment_bounds(spec, 2); + let pressed_bounds: Rect = segmented_control_segment_bounds(spec, 3); + index = built.artifact.display_op_start; + while (index < built.artifact.display_op_end) { + let op: DisplayOp = display.ops.data[index]; + if (op.owner.value == 105) { + selected_op_count = selected_op_count + 1; + if (op.bounds.height == 3 * unit_scale() && + op.bounds.width < selected_bounds.width) { selected_marker = 1; } + if (op.bounds.width > selected_bounds.width && + op.bounds.height > selected_bounds.height) { selected_shadow = 1; } + } + if (op.owner.value == 102 && + op.bounds.x == focus_bounds.x + 2 * unit_scale() && + op.bounds.y == focus_bounds.y + 2 * unit_scale() && + op.bounds.width == focus_bounds.width - 4 * unit_scale()) { + focus_ring_geometry = 1; + } + if (op.owner.value == 103 && + op.bounds.x == pressed_bounds.x + 2 * unit_scale() && + op.bounds.y == pressed_bounds.y + 2 * unit_scale() && + op.bounds.width == pressed_bounds.width - 4 * unit_scale()) { + pressed_inset_geometry = 1; + } + index = index + 1; + } + expect(state, selected_op_count >= 4 && selected_marker != 0 && + selected_shadow != 0, + "selection emits a raised thumb and persistent underline in addition to selected color"); + expect(state, focus_ring_geometry != 0 && pressed_inset_geometry != 0, + "focus and press retain visibly thicker and inset geometry rather than hue-only states"); + + let session: TalkbackSession = talkback_session_make(talkback_capabilities()); + let viewport: Rect = rect(0, 0, 900 * unit_scale(), 400 * unit_scale()); + let selected_query: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(1, TalkbackCommand.query, node_key(105), + semantics.revision)); + let selected_key: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(2, TalkbackCommand.key, node_key(105), + semantics.revision)); + let focused_query: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(3, TalkbackCommand.query, node_key(102), + semantics.revision)); + let disabled_query: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(4, TalkbackCommand.query, node_key(104), + semantics.revision)); + let disabled_key: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(5, TalkbackCommand.key, node_key(104), + semantics.revision)); + expect(state, selected_query.status == TalkbackStatus.accepted && + selected_query.resolved_role == SemanticRole.tab && + (selected_query.state_flags & talkback_state_selected_bit()) != 0 && + selected_query.bounds.x == selected_bounds.x && + selected_key.status == TalkbackStatus.accepted && + selected_key.emitted_action == 1, + "Talkback queries and keyboard-activates the selected segment by stable ID"); + expect(state, focused_query.status == TalkbackStatus.accepted && + (focused_query.state_flags & talkback_state_focused_bit()) != 0 && + semantics.nodes.data[3].focused == 1 && + semantics.nodes.data[3].focus_order == spec.focus_order_base, + "Talkback reports actual semantic focus independently from selected state and focus-ring modality"); + expect(state, disabled_query.status == TalkbackStatus.accepted && + (disabled_query.state_flags & talkback_state_disabled_bit()) != 0 && + disabled_key.status == TalkbackStatus.action_unavailable && + disabled_key.emitted_action == 0, + "Talkback keeps disabled segments queryable while actions fail closed"); + + expect(state, display_list_seal(&display) == DisplayError.none && + display_list_verify(&display) == DisplayError.none && + built.artifact.evidence_hash != 0, + "segmented chrome seals as immutable deterministic DisplayList evidence"); + talkback_session_free(&session); + semantics_tree_free(&semantics); + hit_tree_free(&hits); + display_list_free(&display); + free[SegmentedControlOption](&options); +} + +fn pointer_focus_semantics_contract(state: *TestState) void { + let options: ArrayList[SegmentedControlOption] = + make[SegmentedControlOption](3); + fill_options(&options, 1300, 3); + let model: SegmentedControlModel = segmented_control_model(3, 0); + let pointer: SegmentedControlTransition = segmented_control_reduce(model, + segmented_control_event(SegmentedControlEventKind.pointer_down, 1)); + let spec: SegmentedControlSpec = segmented_control_spec(node_key(1290), + hit_root_key(), rect(20 * unit_scale(), 20 * unit_scale(), + 240 * unit_scale(), 48 * unit_scale()), + "Pointer focus contract", pointer.model); + spec.focus_order_base = 88; + let display: DisplayList = display_list_make(); + let hits: HitTree = hit_tree_make(); + let semantics: SemanticsTree = semantics_tree_make(); + let built: SegmentedControlBuildResult = segmented_control_emit(spec, + &options, &display, &hits, &semantics); + let style: SegmentedControlResolvedStyle = segmented_control_style(spec, 1); + let session: TalkbackSession = talkback_session_make(talkback_capabilities()); + let query: TalkbackResponse = talkback_dispatch(&session, semantics, + rect(0, 0, 400 * unit_scale(), 200 * unit_scale()), + talkback_request(41, TalkbackCommand.query, node_key(1301), + semantics.revision)); + let keyboard_spec: SegmentedControlSpec = spec; + keyboard_spec.model.focus_visible = 1; + let keyboard_display: DisplayList = display_list_make(); + let keyboard_hits: HitTree = hit_tree_make(); + let keyboard_semantics: SemanticsTree = semantics_tree_make(); + let keyboard_built: SegmentedControlBuildResult = segmented_control_emit( + keyboard_spec, &options, &keyboard_display, &keyboard_hits, + &keyboard_semantics); + let keyboard_style: SegmentedControlResolvedStyle = + segmented_control_style(keyboard_spec, 1); + expect(state, built.error == SegmentedControlBuildError.none && + pointer.model.has_focus == 1 && pointer.model.focus_visible == 0 && + style.has_focus == 1 && style.focus_visible == 0 && + style.focused == 0 && style.border_token != SemanticColorToken.focus, + "pointer acquisition owns focus without borrowing the keyboard focus-ring style"); + expect(state, semantics.nodes.data[2].focused == 1 && + semantics.nodes.data[2].focus_order == spec.focus_order_base && + hits.nodes.data[2].focusable == 1 && + query.status == TalkbackStatus.accepted && + (query.state_flags & talkback_state_focused_bit()) != 0, + "semantic and Talkback focus remain truthful when focus-visible styling is intentionally absent"); + expect(state, keyboard_built.error == SegmentedControlBuildError.none && + keyboard_style.focused == 1 && keyboard_style.focus_visible == 1 && + built.artifact.evidence_hash != keyboard_built.artifact.evidence_hash && + display.content_hash != keyboard_display.content_hash && + talkback_tree_hash(semantics) == talkback_tree_hash(keyboard_semantics), + "focus-ring modality changes visual evidence without corrupting identical semantic focus truth"); + semantics_tree_free(&keyboard_semantics); + hit_tree_free(&keyboard_hits); + display_list_free(&keyboard_display); + talkback_session_free(&session); + semantics_tree_free(&semantics); + hit_tree_free(&hits); + display_list_free(&display); + free[SegmentedControlOption](&options); +} + +fn deterministic_contract(state: *TestState) void { + let options: ArrayList[SegmentedControlOption] = make[SegmentedControlOption](6); + fill_options(&options, 300, 6); + let spec: SegmentedControlSpec = six_state_spec(290); + spec.purpose = SegmentedControlPurpose.value_picker; + let display_a: DisplayList = display_list_make(); + let hits_a: HitTree = hit_tree_make(); + let semantics_a: SemanticsTree = semantics_tree_make(); + let display_b: DisplayList = display_list_make(); + let hits_b: HitTree = hit_tree_make(); + let semantics_b: SemanticsTree = semantics_tree_make(); + let first: SegmentedControlBuildResult = segmented_control_emit(spec, + &options, &display_a, &hits_a, &semantics_a); + let second: SegmentedControlBuildResult = segmented_control_emit(spec, + &options, &display_b, &hits_b, &semantics_b); + expect(state, first.error == SegmentedControlBuildError.none && + second.error == SegmentedControlBuildError.none && + first.artifact.evidence_hash == second.artifact.evidence_hash && + display_a.content_hash == display_b.content_hash && + talkback_tree_hash(semantics_a) == talkback_tree_hash(semantics_b) && + first.artifact.purpose == SegmentedControlPurpose.value_picker && + first.artifact.group_role == SemanticRole.radio_group && + semantics_a.nodes.data[0].role == SemanticRole.radio_group && + semantics_a.nodes.data[1].role == SemanticRole.radio && + semantics_b.nodes.data[6].role == SemanticRole.radio, + "value-picker purpose emits deterministic radio semantics instead of tab semantics"); + semantics_tree_free(&semantics_b); hit_tree_free(&hits_b); + display_list_free(&display_b); + semantics_tree_free(&semantics_a); hit_tree_free(&hits_a); + display_list_free(&display_a); + free[SegmentedControlOption](&options); +} + +fn dynamic_borrow_copy_contract(state: *TestState) void { + let label_owner: ArrayList[u8] = make[u8](16); + let description_owner: ArrayList[u8] = make[u8](32); + copy_owned_bytes(&label_owner, "Dynamic mode"); + copy_owned_bytes(&description_owner, "Label bytes are caller owned"); + let options: ArrayList[SegmentedControlOption] = + make[SegmentedControlOption](2); + push[SegmentedControlOption](&options, SegmentedControlOption{ + .id = node_key(1201), + .label = label_owner.data[0..label_owner.len], + .description = description_owner.data[0..description_owner.len], + }); + push[SegmentedControlOption](&options, SegmentedControlOption{ + .id = node_key(1202), + .label = "Static mode", + .description = "Literal peer verifies mixed provenance", + }); + let spec: SegmentedControlSpec = segmented_control_spec(node_key(1200), + hit_root_key(), rect(20 * unit_scale(), 20 * unit_scale(), + 240 * unit_scale(), 48 * unit_scale()), + "Borrowed option copy", segmented_control_model(2, 0)); + let display: DisplayList = display_list_make(); + let hits: HitTree = hit_tree_make(); + let semantics: SemanticsTree = semantics_tree_make(); + let built: SegmentedControlBuildResult = segmented_control_emit(spec, + &options, &display, &hits, &semantics); + + // The call has copied semantic text. End every caller-side view and owner + // before inspecting the retained tree; a surviving aggregate loan must make + // this release fail compilation rather than being hidden by the component. + free[SegmentedControlOption](&options); + free[u8](&label_owner); + free[u8](&description_owner); + + let session: TalkbackSession = talkback_session_make(talkback_capabilities()); + let viewport: Rect = rect(0, 0, 400 * unit_scale(), 200 * unit_scale()); + let query: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(51, TalkbackCommand.query, node_key(1201), + semantics.revision)); + expect(state, built.error == SegmentedControlBuildError.none && + semantic_text_equal(semantics.nodes.data[1].name, "Dynamic mode") != 0 && + semantic_text_equal(semantics.nodes.data[1].description, + "Label bytes are caller owned") != 0 && + query.status == TalkbackStatus.accepted && + query.name_length == 12 && query.description_length == 28, + "dynamic borrowed option bytes end at emission while retained semantics keep an owned copy"); + talkback_session_free(&session); + semantics_tree_free(&semantics); + hit_tree_free(&hits); + display_list_free(&display); +} + +fn builders_restored(display: *DisplayList, + hits: *HitTree, + semantics: *SemanticsTree, + display_length: i32, display_revision: i64, + display_hash: i64, hit_length: i32, hit_revision: i64, + semantic_length: i32, semantic_revision: i64) i32 { + return (display.*.ops.len == display_length && + display.*.revision == display_revision && + display.*.content_hash == display_hash && + hits.*.nodes.len == hit_length && hits.*.revision == hit_revision && + semantics.*.nodes.len == semantic_length && + semantics.*.revision == semantic_revision) as i32; +} + +fn capacity_and_atomicity_contract(state: *TestState) void { + let options: ArrayList[SegmentedControlOption] = make[SegmentedControlOption](6); + fill_options(&options, 500, 6); + let base: SegmentedControlSpec = six_state_spec(490); + let display: DisplayList = display_list_make(); + let hits: HitTree = hit_tree_make(); + let semantics: SemanticsTree = semantics_tree_make(); + let display_length: i32 = display.ops.len; + let display_revision: i64 = display.revision; + let display_hash: i64 = display.content_hash; + let hit_length: i32 = hits.nodes.len; + let hit_revision: i64 = hits.revision; + let semantic_length: i32 = semantics.nodes.len; + let semantic_revision: i64 = semantics.revision; + + let display_limited: SegmentedControlSpec = base; + display_limited.limits.display_operations = 3; + let display_failure: SegmentedControlBuildResult = segmented_control_emit( + display_limited, &options, &display, &hits, &semantics); + expect(state, display_failure.error == SegmentedControlBuildError.capacity_error && + display_failure.capacity_error == + SegmentedControlCapacityError.display_operations && + builders_restored(&display, &hits, &semantics, display_length, + display_revision, display_hash, hit_length, hit_revision, + semantic_length, semantic_revision) != 0, + "display-operation capacity failure rolls every builder back exactly"); + + let hit_limited: SegmentedControlSpec = base; + hit_limited.limits.hit_nodes = 2; + let hit_failure: SegmentedControlBuildResult = segmented_control_emit( + hit_limited, &options, &display, &hits, &semantics); + expect(state, hit_failure.error == SegmentedControlBuildError.capacity_error && + hit_failure.capacity_error == SegmentedControlCapacityError.hit_nodes && + builders_restored(&display, &hits, &semantics, display_length, + display_revision, display_hash, hit_length, hit_revision, + semantic_length, semantic_revision) != 0, + "hit-node capacity failure rolls rendered operations and hit nodes back"); + + let semantic_limited: SegmentedControlSpec = base; + semantic_limited.limits.semantic_nodes = 2; + let semantic_failure: SegmentedControlBuildResult = segmented_control_emit( + semantic_limited, &options, &display, &hits, &semantics); + expect(state, semantic_failure.error == SegmentedControlBuildError.capacity_error && + semantic_failure.capacity_error == SegmentedControlCapacityError.semantic_nodes && + builders_restored(&display, &hits, &semantics, display_length, + display_revision, display_hash, hit_length, hit_revision, + semantic_length, semantic_revision) != 0, + "semantic capacity failure frees copied text and restores every builder"); + + let valid: SegmentedControlBuildResult = segmented_control_emit(base, + &options, &display, &hits, &semantics); + let committed_display_length: i32 = display.ops.len; + let committed_display_revision: i64 = display.revision; + let committed_display_hash: i64 = display.content_hash; + let committed_hit_length: i32 = hits.nodes.len; + let committed_hit_revision: i64 = hits.revision; + let committed_semantic_length: i32 = semantics.nodes.len; + let committed_semantic_revision: i64 = semantics.revision; + let duplicate: SegmentedControlBuildResult = segmented_control_emit(base, + &options, &display, &hits, &semantics); + expect(state, valid.error == SegmentedControlBuildError.none && + duplicate.error == SegmentedControlBuildError.hit_error && + duplicate.hit_error == HitTreeError.duplicate_id && + builders_restored(&display, &hits, &semantics, committed_display_length, + committed_display_revision, committed_display_hash, + committed_hit_length, committed_hit_revision, + committed_semantic_length, committed_semantic_revision) != 0, + "duplicate stable IDs return the hit error without disturbing prior content"); + + semantics_tree_free(&semantics); + hit_tree_free(&hits); + display_list_free(&display); + free[SegmentedControlOption](&options); + + let invalid_options: ArrayList[SegmentedControlOption] = + make[SegmentedControlOption](6); + fill_options(&invalid_options, 700, 6); + invalid_options.data[1].id = invalid_options.data[0].id; + let invalid_display: DisplayList = display_list_make(); + let invalid_hits: HitTree = hit_tree_make(); + let invalid_semantics: SemanticsTree = semantics_tree_make(); + let invalid: SegmentedControlBuildResult = segmented_control_emit( + six_state_spec(690), &invalid_options, &invalid_display, + &invalid_hits, &invalid_semantics); + expect(state, invalid.error == SegmentedControlBuildError.invalid_spec && + invalid_display.ops.len == 0 && invalid_hits.nodes.len == 0 && + invalid_semantics.nodes.len == 0, + "duplicate option IDs fail validation before retained mutation"); + semantics_tree_free(&invalid_semantics); hit_tree_free(&invalid_hits); + display_list_free(&invalid_display); + free[SegmentedControlOption](&invalid_options); + + let sealed_options: ArrayList[SegmentedControlOption] = + make[SegmentedControlOption](6); + fill_options(&sealed_options, 800, 6); + let sealed_display: DisplayList = display_list_make(); + let sealed_hits: HitTree = hit_tree_make(); + let sealed_semantics: SemanticsTree = semantics_tree_make(); + _ = display_list_seal(&sealed_display); + let sealed: SegmentedControlBuildResult = segmented_control_emit( + six_state_spec(790), &sealed_options, &sealed_display, + &sealed_hits, &sealed_semantics); + expect(state, sealed.error == SegmentedControlBuildError.display_error && + sealed.display_error == DisplayError.sealed && + sealed_display.ops.len == 0 && sealed_hits.nodes.len == 0 && + sealed_semantics.nodes.len == 0, + "sealed destinations fail visibly without partial trees"); + semantics_tree_free(&sealed_semantics); hit_tree_free(&sealed_hits); + display_list_free(&sealed_display); + free[SegmentedControlOption](&sealed_options); + + let collision_options: ArrayList[SegmentedControlOption] = + make[SegmentedControlOption](6); + fill_options(&collision_options, 900, 6); + let collision_display: DisplayList = display_list_make(); + let collision_hits: HitTree = hit_tree_make(); + let collision_semantics: SemanticsTree = semantics_tree_make(); + let occupied: SemanticsSpec = semantics_spec(node_key(9999), + semantic_root_key(), SemanticRole.button, "Existing focus target"); + occupied.focus_order = 20; + _ = semantics_add(&collision_semantics, occupied); + let collision: SegmentedControlBuildResult = segmented_control_emit( + six_state_spec(890), &collision_options, &collision_display, + &collision_hits, &collision_semantics); + expect(state, collision.error == SegmentedControlBuildError.semantics_error && + collision.semantics_error == SemanticsError.duplicate_focus_order && + collision_display.ops.len == 0 && collision_hits.nodes.len == 0 && + collision_semantics.nodes.len == 1 && + collision_semantics.revision == 1 && + collision_semantics.nodes.data[0].id.value == 9999, + "semantic focus collisions free staged text and restore other builders"); + semantics_tree_free(&collision_semantics); + hit_tree_free(&collision_hits); + display_list_free(&collision_display); + free[SegmentedControlOption](&collision_options); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + model_and_reducer_contract(&state); + exhaustive_selection_property_contract(&state); + placement_and_state_contract(&state); + retained_tree_contract(&state); + pointer_focus_semantics_contract(&state); + deterministic_contract(&state); + dynamic_borrow_copy_contract(&state); + capacity_and_atomicity_contract(&state); + _zag_print("SegmentedControl contract: pass="); + _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); + _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tests/surface_contract.zag b/tests/surface_contract.zag new file mode 100644 index 0000000..95c0b88 --- /dev/null +++ b/tests/surface_contract.zag @@ -0,0 +1,671 @@ +@import("../src/components/surface.zag") +@import("../src/automation/talkback.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, + condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn bytes_equal(left: []u8, right: []u8) i32 { + if (left.len != right.len) { return 0; } + let index: i32 = 0; + while (index < left.len) { + if (left[index] != right[index]) { return 0; } + index = index + 1; + } + return 1; +} + +fn copy_bytes(destination: *ArrayList[u8], + source: []u8) void { + let index: i32 = 0; + while (index < source.len) { + push[u8](destination, source[index]); + index = index + 1; + } +} + +fn action_fixture(id: i64, state: SurfaceCanonicalState) SurfaceSpec { + let spec: SurfaceSpec = surface_action_spec(node_key(id), hit_root_key(), + rect(20 * unit_scale(), + (20 + (id - 200) * 82) * unit_scale(), + 240 * unit_scale(), 72 * unit_scale()), + "Action card", SurfaceTier.raised); + spec.description = "Opens the selected toolkit detail"; + spec.focus_order = id - 199; + spec.z_order = id; + spec.interaction = surface_canonical_state(state); + if (state == SurfaceCanonicalState.loading) { + spec.live_region = LiveRegion.polite; + } + if (state == SurfaceCanonicalState.error) { + spec.live_region = LiveRegion.assertive; + } + return spec; +} + +fn expected_material(index: i32) SemanticMaterialToken { + return switch (index) { + 0 => SemanticMaterialToken.solid, + 1 => SemanticMaterialToken.veil, + 2 => SemanticMaterialToken.glass, + else => SemanticMaterialToken.glass, + }; +} + +fn expected_elevation(index: i32) SemanticElevationToken { + return switch (index) { + 0 => SemanticElevationToken.base, + 1 => SemanticElevationToken.panel, + 2 => SemanticElevationToken.raised, + else => SemanticElevationToken.overlay, + }; +} + +fn expected_spacing(index: i32) FlexSpacingToken { + if (index == 0) { return FlexSpacingToken.medium; } + if (index == 1) { return FlexSpacingToken.large; } + return FlexSpacingToken.xlarge; +} + +fn expected_material_name(index: i32) []u8 { + return switch (index) { + 0 => "material.solid", + 1 => "material.veil", + 2 => "material.glass", + else => "material.glass", + }; +} + +fn reducer_contract(state: *TestState) void { + let initial: ButtonInteraction = surface_interaction(); + let group: SurfaceTransition = surface_reduce(SurfaceMode.group, + initial, ButtonEvent.pointer_down); + expect(state, group.rejected == 1 && group.activate == 0 && + button_interaction_equal(group.interaction, initial) != 0, + "noninteractive groups reject input without mutating state"); + + let entered: SurfaceTransition = surface_reduce(SurfaceMode.actionable, + initial, ButtonEvent.pointer_enter); + let pressed: SurfaceTransition = surface_reduce(SurfaceMode.actionable, + entered.interaction, ButtonEvent.pointer_down); + let activated: SurfaceTransition = surface_reduce(SurfaceMode.actionable, + pressed.interaction, ButtonEvent.pointer_up_inside); + expect(state, entered.interaction.hovered == 1 && + pressed.interaction.pressed == 1 && activated.activate == 1 && + activated.interaction.pressed == 0 && activated.rejected == 0, + "actionable cards use one state-down action-up reducer contract"); + + let loading: SurfaceTransition = surface_reduce(SurfaceMode.actionable, + pressed.interaction, ButtonEvent.begin_loading); + let ignored: SurfaceTransition = surface_reduce(SurfaceMode.actionable, + loading.interaction, ButtonEvent.pointer_down); + expect(state, loading.interaction.loading == 1 && + loading.interaction.pressed == 0 && ignored.interaction.pressed == 0, + "loading cards fail closed to new presses"); +} + +fn focus_modality_contract(state: *TestState) void { + let initial: ButtonInteraction = surface_interaction(); + let pointer_focus: SurfaceTransition = surface_reduce( + SurfaceMode.actionable, initial, ButtonEvent.focus_gained_pointer); + let keyboard_focus: SurfaceTransition = surface_reduce( + SurfaceMode.actionable, initial, ButtonEvent.focus_gained_keyboard); + expect(state, pointer_focus.interaction.focused == 1 && + pointer_focus.interaction.focus_visible == 0 && + keyboard_focus.interaction.focused == 1 && + keyboard_focus.interaction.focus_visible == 1, + "pointer and keyboard focus share actual focus but preserve ring modality"); + + let display: DisplayList = display_list_make(); + let hits: HitTree = hit_tree_make(); + let semantics: SemanticsTree = semantics_tree_make(); + let spec: SurfaceSpec = surface_action_spec(node_key(90), hit_root_key(), + rect(20 * unit_scale(), 20 * unit_scale(), + 240 * unit_scale(), 72 * unit_scale()), + "Pointer-focused card", SurfaceTier.raised); + spec.interaction = pointer_focus.interaction; + let built: SurfaceBuildResult = surface_emit(spec, + &display, &hits, &semantics); + let session: TalkbackSession = talkback_session_make(talkback_capabilities()); + let viewport: Rect = rect(0, 0, 400 * unit_scale(), 200 * unit_scale()); + let query: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(90, TalkbackCommand.query, node_key(90), + semantics.revision)); + expect(state, built.error == SurfaceBuildError.none && + built.artifact.interaction.focused == 1 && + built.artifact.interaction.focus_visible == 0 && + built.artifact.focus_ring_visible == 0 && + semantics.nodes.data[0].focused == 1 && + (query.state_flags & talkback_state_focused_bit()) != 0, + "pointer focus is semantic and Talkback-visible without painting a keyboard ring"); + talkback_session_free(&session); + semantics_tree_free(&semantics); + hit_tree_free(&hits); + display_list_free(&display); +} + +fn hierarchy_and_spacing_contract(state: *TestState) void { + let display: DisplayList = display_list_make(); + let hits: HitTree = hit_tree_make(); + let semantics: SemanticsTree = semantics_tree_make(); + + let index: i32 = 0; + let previous_hash: i64 = 0; + while (index < 4) { + let bounds: Rect = rect(20 * unit_scale(), + (20 + index * 100) as i64 * unit_scale(), + 260 * unit_scale(), 80 * unit_scale()); + let spec: SurfaceSpec = surface_spec(node_key(100 + index), + hit_root_key(), bounds, "Hierarchy tier", index as SurfaceTier); + spec.description = "Canonical noninteractive surface"; + spec.z_order = index as i64; + if (index == 2) { spec.live_region = LiveRegion.polite; } + let built: SurfaceBuildResult = surface_emit(spec, + &display, &hits, &semantics); + let spacing: i64 = flex_spacing(expected_spacing(index), + FlexDensity.standard); + let material: ResolvedMaterialToken = semantic_material( + built.artifact.style.material_token); + expect(state, built.error == SurfaceBuildError.none && + built.artifact.style.material_token == expected_material(index) && + built.artifact.style.material_elevation_token == + expected_elevation(index) && + built.artifact.style.radius_token == material.radius_token && + built.artifact.style.material_fill_token == material.fill_token && + built.artifact.style.edge_token == material.edge_token && + bytes_equal(material.id, expected_material_name(index)) != 0, + "each hierarchy tier exposes named material elevation radius and color provenance"); + expect(state, built.artifact.content_bounds.x == bounds.x + spacing && + built.artifact.content_bounds.y == bounds.y + spacing && + built.artifact.content_bounds.width == bounds.width - spacing * 2 && + built.artifact.content_bounds.height == bounds.height - spacing * 2 && + built.artifact.style.content_spacing_token == + expected_spacing(index), + "each hierarchy tier returns exact Flex token content geometry"); + let op_index: i32 = built.artifact.display_op_start; + let owners_match: i32 = 1; + while (op_index < built.artifact.display_op_end) { + if (node_key_equal(display.ops.data[op_index].owner, spec.id) == 0) { + owners_match = 0; + } + op_index = op_index + 1; + } + expect(state, owners_match != 0 && + node_key_equal(hits.nodes.data[index].id, spec.id) != 0 && + node_key_equal(semantics.nodes.data[index].id, spec.id) != 0 && + semantics.nodes.data[index].role == SemanticRole.group && + hits.nodes.data[index].enabled == 0 && + semantics.nodes.data[index].action_mask == 0, + "pixels semantics hits and ID automation share one non-actionable group key"); + expect(state, built.artifact.evidence_hash != 0 && + (index == 0 || built.artifact.evidence_hash != previous_hash), + "tier evidence changes deterministically with hierarchy inputs"); + previous_hash = built.artifact.evidence_hash; + index = index + 1; + } + expect(state, display.ops.len == 18 && hits.nodes.len == 4 && + semantics.nodes.len == 4 && + semantics.nodes.data[2].live_region == LiveRegion.polite, + "base panel raised and overlay contribute deliberately different retained depth"); + + let session: TalkbackSession = talkback_session_make(talkback_capabilities()); + let viewport: Rect = rect(0, 0, 400 * unit_scale(), 500 * unit_scale()); + let query: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(1, TalkbackCommand.query, node_key(102), + semantics.revision)); + let click: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(2, TalkbackCommand.click, node_key(102), + semantics.revision)); + expect(state, query.status == TalkbackStatus.accepted && + query.resolved_role == SemanticRole.group && + click.status == TalkbackStatus.action_unavailable && + click.emitted_action == 0, + "Talkback discovers group surfaces by ID while actions remain unavailable"); + expect(state, display_list_seal(&display) == DisplayError.none && + display_list_verify(&display) == DisplayError.none, + "all four surface tiers seal as one immutable verified display list"); + + talkback_session_free(&session); + semantics_tree_free(&semantics); + hit_tree_free(&hits); + display_list_free(&display); +} + +fn state_matrix_contract(state: *TestState) void { + let display: DisplayList = display_list_make(); + let hits: HitTree = hit_tree_make(); + let semantics: SemanticsTree = semantics_tree_make(); + let previous_hash: i64 = 0; + let aggregate_hash: i64 = 17; + let index: i32 = 0; + while (index < 8) { + let spec: SurfaceSpec = action_fixture(200 + index, + index as SurfaceCanonicalState); + let built: SurfaceBuildResult = surface_emit(spec, + &display, &hits, &semantics); + expect(state, built.error == SurfaceBuildError.none && + built.artifact.hit_node_index == index && + built.artifact.semantics_node_index == index && + built.artifact.semantics_role == SemanticRole.button && + node_key_equal(hits.nodes.data[index].id, spec.id) != 0 && + node_key_equal(semantics.nodes.data[index].id, spec.id) != 0, + "each actionable state shares one exact display hit semantic and Talkback ID"); + expect(state, built.artifact.evidence_hash != 0 && + (index == 0 || built.artifact.evidence_hash != previous_hash), + "default hover focus pressed selected disabled loading and error evidence differ"); + previous_hash = built.artifact.evidence_hash; + aggregate_hash = surface_mix(aggregate_hash, + built.artifact.evidence_hash); + index = index + 1; + } + + expect(state, semantic_text_equal(semantics.nodes.data[0].value, "Ready") != 0 && + semantic_text_equal(semantics.nodes.data[1].value, "Hovered") != 0 && + semantic_text_equal(semantics.nodes.data[2].value, "Focused") != 0 && + semantic_text_equal(semantics.nodes.data[3].value, "Pressed") != 0 && + semantic_text_equal(semantics.nodes.data[4].value, "Selected") != 0 && + semantic_text_equal(semantics.nodes.data[5].value, "Disabled") != 0 && + semantic_text_equal(semantics.nodes.data[6].value, "Loading") != 0 && + semantic_text_equal(semantics.nodes.data[7].value, "Error") != 0, + "every canonical interaction has explicit non-color semantic state text"); + expect(state, semantics.nodes.data[4].selected == 1 && + semantics.nodes.data[5].disabled == 1 && + semantics.nodes.data[5].action_mask == 0 && + hits.nodes.data[5].enabled == 0 && + semantics.nodes.data[6].disabled == 1 && + semantics.nodes.data[6].live_region == LiveRegion.polite && + semantics.nodes.data[7].live_region == LiveRegion.assertive, + "selected disabled loading and error truth agrees across semantics hit and live regions"); + expect(state, semantics.nodes.data[2].focused == 1 && + semantics.nodes.data[0].focused == 0 && + semantic_actions_has(semantics.nodes.data[2].action_mask, + SemanticAction.focus) != 0, + "focused styling is backed by one retained semantic focus owner"); + expect(state, semantic_actions_has(semantics.nodes.data[0].action_mask, + SemanticAction.activate) != 0 && + semantic_actions_has(semantics.nodes.data[0].action_mask, + SemanticAction.focus) != 0 && + semantics.nodes.data[7].disabled == 0, + "ready and error cards remain genuinely actionable"); + + let hover_spec: SurfaceSpec = action_fixture(250, + SurfaceCanonicalState.hover); + let hover_style: SurfaceResolvedStyle = surface_style(hover_spec); + let selected_spec: SurfaceSpec = action_fixture(251, + SurfaceCanonicalState.selected); + let selected_style: SurfaceResolvedStyle = surface_style(selected_spec); + let disabled_spec: SurfaceSpec = action_fixture(252, + SurfaceCanonicalState.disabled); + let disabled_style: SurfaceResolvedStyle = surface_style(disabled_spec); + let error_spec: SurfaceSpec = action_fixture(253, + SurfaceCanonicalState.error); + let error_style: SurfaceResolvedStyle = surface_style(error_spec); + expect(state, hover_style.fill_token == SemanticColorToken.surface_interactive && + hover_style.state_token == SemanticColorToken.accent && + selected_style.fill_token == SemanticColorToken.surface_selected && + selected_style.state_token == SemanticColorToken.accent && + disabled_style.content_color_token == SemanticColorToken.text_disabled && + error_style.state_token == SemanticColorToken.status_error, + "state colors resolve only through named semantic tokens"); + + let hover_start: i32 = 0; + let focus_start: i32 = 0; + let pressed_start: i32 = 0; + let selected_start: i32 = 0; + let disabled_start: i32 = 0; + let loading_start: i32 = 0; + let error_start: i32 = 0; + // Retained ranges are recovered from deterministic per-state builds. + index = 0; + while (index < 8) { + if (hits.nodes.data[index].id.value == 201) { + hover_start = index; + } + if (hits.nodes.data[index].id.value == 202) { focus_start = index; } + if (hits.nodes.data[index].id.value == 203) { pressed_start = index; } + if (hits.nodes.data[index].id.value == 204) { selected_start = index; } + if (hits.nodes.data[index].id.value == 205) { disabled_start = index; } + if (hits.nodes.data[index].id.value == 206) { loading_start = index; } + if (hits.nodes.data[index].id.value == 207) { error_start = index; } + index = index + 1; + } + expect(state, hover_start == 1 && focus_start == 2 && pressed_start == 3 && + selected_start == 4 && disabled_start == 5 && loading_start == 6 && + error_start == 7 && aggregate_hash != 0, + "the complete state matrix remains stable and addressable by ID"); + + let hit: HitResult = hit_test(hits, 30 * unit_scale(), 30 * unit_scale()); + expect(state, hit.found != 0 && hit.id.value == 200, + "pointer routing resolves the same stable actionable card ID"); + + let session: TalkbackSession = talkback_session_make(talkback_capabilities()); + let viewport: Rect = rect(0, 0, 400 * unit_scale(), 800 * unit_scale()); + let selected_query: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(10, TalkbackCommand.query, node_key(204), + semantics.revision)); + let ready_click: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(11, TalkbackCommand.click, node_key(200), + semantics.revision)); + let disabled_click: TalkbackResponse = talkback_dispatch(&session, semantics, + viewport, talkback_request(12, TalkbackCommand.click, node_key(205), + semantics.revision)); + let pixel_fallback: TalkbackPixelFallbackTarget = + talkback_pixel_fallback_for_id(semantics, viewport, node_key(204), + TalkbackCommand.click, 2, 1); + expect(state, selected_query.status == TalkbackStatus.accepted && + (selected_query.state_flags & 2) != 0 && + ready_click.status == TalkbackStatus.accepted && + ready_click.emitted_action == 1 && + disabled_click.status == TalkbackStatus.action_unavailable && + disabled_click.emitted_action == 0, + "Talkback queries and activates Surface by semantics ID without pixel routing"); + expect(state, pixel_fallback.status == TalkbackStatus.accepted && + pixel_fallback.source_id.value == 204 && + pixel_fallback.bounds_x == 20 * unit_scale() && + pixel_fallback.bounds_y == 348 * unit_scale() && + pixel_fallback.pixel_x == 280 && pixel_fallback.pixel_y == 768 && + pixel_fallback.action_available == 1 && + pixel_fallback.evidence_hash != 0, + "ID-derived pixel fallback records the same Surface geometry and source key"); + expect(state, display_list_seal(&display) == DisplayError.none && + display_list_verify(&display) == DisplayError.none, + "all eight actionable states seal and verify"); + + talkback_session_free(&session); + semantics_tree_free(&semantics); + hit_tree_free(&hits); + display_list_free(&display); +} + +fn visible_treatment_contract(state: *TestState) void { + let display: DisplayList = display_list_make(); + let hits: HitTree = hit_tree_make(); + let semantics: SemanticsTree = semantics_tree_make(); + let hover: SurfaceBuildResult = surface_emit(action_fixture(400, + SurfaceCanonicalState.hover), &display, &hits, &semantics); + let focus: SurfaceBuildResult = surface_emit(action_fixture(401, + SurfaceCanonicalState.focus), &display, &hits, &semantics); + let pressed: SurfaceBuildResult = surface_emit(action_fixture(402, + SurfaceCanonicalState.pressed), &display, &hits, &semantics); + let selected: SurfaceBuildResult = surface_emit(action_fixture(403, + SurfaceCanonicalState.selected), &display, &hits, &semantics); + let disabled: SurfaceBuildResult = surface_emit(action_fixture(404, + SurfaceCanonicalState.disabled), &display, &hits, &semantics); + let loading: SurfaceBuildResult = surface_emit(action_fixture(405, + SurfaceCanonicalState.loading), &display, &hits, &semantics); + let error: SurfaceBuildResult = surface_emit(action_fixture(406, + SurfaceCanonicalState.error), &display, &hits, &semantics); + expect(state, hover.artifact.hover_treatment_visible == 1 && + hover.artifact.display_op_end - hover.artifact.display_op_start >= 5 && + display.ops.data[hover.artifact.display_op_end - 1].bounds.width == + 3 * unit_scale(), + "hover adds a geometric trailing marker beyond its color token"); + expect(state, focus.artifact.focus_ring_visible == 1 && + display.ops.data[focus.artifact.display_op_start].bounds.width == + focus.artifact.bounds.width + 6 * unit_scale(), + "focus adds a retained ring outside the material surface"); + expect(state, pressed.artifact.pressed_treatment_visible == 1 && + pressed.artifact.chrome_bounds.x == pressed.artifact.bounds.x + + 2 * unit_scale() && + pressed.artifact.chrome_bounds.width == pressed.artifact.bounds.width - + 4 * unit_scale() && + pressed.artifact.style.elevation_token == SemanticElevationToken.base, + "pressed state depresses geometry and removes raised elevation"); + expect(state, selected.artifact.selected_marker_visible == 1 && + display.ops.data[selected.artifact.display_op_end - 1].bounds.height == + 3 * unit_scale(), + "selection adds a persistent geometric underline"); + expect(state, disabled.artifact.disabled_treatment_visible == 1 && + disabled.artifact.effectively_enabled == 0 && + disabled.artifact.display_op_end - disabled.artifact.display_op_start >= 5, + "disabled state adds a two-line unavailable mark and removes actionability"); + expect(state, loading.artifact.loading_treatment_visible == 1 && + display.ops.data[loading.artifact.display_op_end - 2].bounds.width == + 22 * unit_scale() && + display.ops.data[loading.artifact.display_op_end - 1].bounds.width == + 13 * unit_scale(), + "loading adds a two-part progress rail instead of changing fill alone"); + expect(state, error.artifact.error_treatment_visible == 1 && + display.ops.data[error.artifact.display_op_end - 1].bounds.width == + flex_spacing(FlexSpacingToken.tiny, FlexDensity.standard) && + display.ops.data[error.artifact.display_op_end - 1].bounds.height == + flex_spacing(FlexSpacingToken.xlarge, FlexDensity.standard), + "error adds a Flex-sized trailing state rail in addition to its status token"); + semantics_tree_free(&semantics); + hit_tree_free(&hits); + display_list_free(&display); +} + +fn dynamic_return_lifetime_contract(state: *TestState) void { + let name_owner: ArrayList[u8] = make[u8](32); + copy_bytes(&name_owner, "Dynamic raised surface"); + let spec: SurfaceSpec = surface_spec(node_key(500), hit_root_key(), + rect(20 * unit_scale(), 20 * unit_scale(), + 260 * unit_scale(), 80 * unit_scale()), + name_owner.data[0..name_owner.len], SurfaceTier.raised); + let display: DisplayList = display_list_make(); + let hits: HitTree = hit_tree_make(); + let semantics: SemanticsTree = semantics_tree_make(); + let built: SurfaceBuildResult = surface_emit(spec, + &display, &hits, &semantics); + + // The constructor's retained-return loan ends at Surface emission; semantic + // text is now owned by the retained tree, so caller backing can be released. + free[u8](&name_owner); + expect(state, built.error == SurfaceBuildError.none && + semantic_text_equal(semantics.nodes.data[0].name, + "Dynamic raised surface") != 0, + "dynamic constructor text stays alive through emission then becomes owned semantic text"); + semantics_tree_free(&semantics); + hit_tree_free(&hits); + display_list_free(&display); +} + +fn builders_restored(display: *DisplayList, + hits: *HitTree, semantics: *SemanticsTree, + display_length: i32, display_revision: i64, display_hash: i64, + hit_length: i32, hit_revision: i64, + semantic_length: i32, semantic_revision: i64) i32 { + return (display.*.ops.len == display_length && + display.*.revision == display_revision && + display.*.content_hash == display_hash && + hits.*.nodes.len == hit_length && hits.*.revision == hit_revision && + semantics.*.nodes.len == semantic_length && + semantics.*.revision == semantic_revision) as i32; +} + +fn capacity_and_atomicity_contract(state: *TestState) void { + let display: DisplayList = display_list_make(); + let hits: HitTree = hit_tree_make(); + let semantics: SemanticsTree = semantics_tree_make(); + let seed: SurfaceSpec = surface_action_spec(node_key(600), hit_root_key(), + rect(20 * unit_scale(), 20 * unit_scale(), + 240 * unit_scale(), 72 * unit_scale()), + "Committed card", SurfaceTier.panel); + let committed: SurfaceBuildResult = surface_emit(seed, + &display, &hits, &semantics); + let display_length: i32 = display.ops.len; + let display_revision: i64 = display.revision; + let display_hash: i64 = display.content_hash; + let hit_length: i32 = hits.nodes.len; + let hit_revision: i64 = hits.revision; + let semantic_length: i32 = semantics.nodes.len; + let semantic_revision: i64 = semantics.revision; + + let display_limited: SurfaceSpec = action_fixture(610, + SurfaceCanonicalState.error); + display_limited.focus_order = 2; + display_limited.limits.display_operations = 2; + let display_failure: SurfaceBuildResult = surface_emit(display_limited, + &display, &hits, &semantics); + expect(state, committed.error == SurfaceBuildError.none && + display_failure.error == SurfaceBuildError.capacity_error && + display_failure.capacity_error == + SurfaceCapacityError.display_operations && + builders_restored(&display, &hits, &semantics, + display_length, display_revision, display_hash, + hit_length, hit_revision, semantic_length, semantic_revision) != 0, + "display capacity exhaustion atomically restores every retained builder"); + + let hit_limited: SurfaceSpec = action_fixture(611, + SurfaceCanonicalState.selected); + hit_limited.focus_order = 2; + hit_limited.limits.hit_nodes = 0; + let hit_failure: SurfaceBuildResult = surface_emit(hit_limited, + &display, &hits, &semantics); + expect(state, hit_failure.error == SurfaceBuildError.capacity_error && + hit_failure.capacity_error == SurfaceCapacityError.hit_nodes && + builders_restored(&display, &hits, &semantics, + display_length, display_revision, display_hash, + hit_length, hit_revision, semantic_length, semantic_revision) != 0, + "hit capacity exhaustion rolls back all prior surface display operations"); + + let semantic_limited: SurfaceSpec = action_fixture(612, + SurfaceCanonicalState.loading); + semantic_limited.focus_order = 2; + semantic_limited.limits.semantic_nodes = 0; + let semantic_failure: SurfaceBuildResult = surface_emit(semantic_limited, + &display, &hits, &semantics); + expect(state, semantic_failure.error == SurfaceBuildError.capacity_error && + semantic_failure.capacity_error == SurfaceCapacityError.semantic_nodes && + builders_restored(&display, &hits, &semantics, + display_length, display_revision, display_hash, + hit_length, hit_revision, semantic_length, semantic_revision) != 0, + "semantic capacity exhaustion frees staged text and restores display and hits"); + + let duplicate: SurfaceBuildResult = surface_emit(seed, + &display, &hits, &semantics); + expect(state, duplicate.error == SurfaceBuildError.hit_error && + duplicate.hit_error == HitTreeError.duplicate_id && + builders_restored(&display, &hits, &semantics, + display_length, display_revision, display_hash, + hit_length, hit_revision, semantic_length, semantic_revision) != 0, + "duplicate stable IDs fail visibly without partial contributions"); + + let collision: SurfaceSpec = action_fixture(613, + SurfaceCanonicalState.rest); + collision.focus_order = 1; + let semantic_collision: SurfaceBuildResult = surface_emit(collision, + &display, &hits, &semantics); + expect(state, semantic_collision.error == SurfaceBuildError.semantics_error && + semantic_collision.semantics_error == + SemanticsError.duplicate_focus_order && + builders_restored(&display, &hits, &semantics, + display_length, display_revision, display_hash, + hit_length, hit_revision, semantic_length, semantic_revision) != 0, + "semantic focus collisions also roll back pixels hits and copied text"); + + semantics_tree_free(&semantics); + hit_tree_free(&hits); + display_list_free(&display); +} + +fn invalid_and_deterministic_contract(state: *TestState) void { + let invalid_display: DisplayList = display_list_make(); + let invalid_hits: HitTree = hit_tree_make(); + let invalid_semantics: SemanticsTree = semantics_tree_make(); + let interactive_group: SurfaceSpec = surface_spec(node_key(700), + hit_root_key(), rect(0, 0, 100 * unit_scale(), 60 * unit_scale()), + "Invalid group", SurfaceTier.base); + interactive_group.interaction = surface_canonical_state( + SurfaceCanonicalState.hover); + let no_focus: SurfaceSpec = surface_action_spec(node_key(701), + hit_root_key(), rect(0, 0, 100 * unit_scale(), 60 * unit_scale()), + "No focus order", SurfaceTier.base); + no_focus.focus_order = 0; + let too_small: SurfaceSpec = surface_action_spec(node_key(702), + hit_root_key(), rect(0, 0, 43 * unit_scale(), 43 * unit_scale()), + "Too small", SurfaceTier.base); + let invalid_tier: SurfaceSpec = surface_spec(node_key(703), + hit_root_key(), rect(0, 0, 100 * unit_scale(), 60 * unit_scale()), + "Invalid tier", 99 as SurfaceTier); + let invalid_limits: SurfaceSpec = surface_spec(node_key(704), + hit_root_key(), rect(0, 0, 100 * unit_scale(), 60 * unit_scale()), + "Invalid limits", SurfaceTier.base); + invalid_limits.limits.display_operations = 0 - 1; + expect(state, surface_minimum_action_target() == 44 * unit_scale() && + surface_emit(interactive_group, &invalid_display, &invalid_hits, + &invalid_semantics).error == SurfaceBuildError.invalid_spec && + surface_emit(no_focus, &invalid_display, &invalid_hits, + &invalid_semantics).error == SurfaceBuildError.invalid_spec && + surface_emit(too_small, &invalid_display, &invalid_hits, + &invalid_semantics).error == SurfaceBuildError.invalid_spec && + surface_emit(invalid_tier, &invalid_display, &invalid_hits, + &invalid_semantics).error == SurfaceBuildError.invalid_spec && + surface_emit(invalid_limits, &invalid_display, &invalid_hits, + &invalid_semantics).error == SurfaceBuildError.invalid_spec && + invalid_display.ops.len == 0 && invalid_hits.nodes.len == 0 && + invalid_semantics.nodes.len == 0, + "invalid modes targets enums and limits fail closed before mutation"); + semantics_tree_free(&invalid_semantics); + hit_tree_free(&invalid_hits); + display_list_free(&invalid_display); + + let display_a: DisplayList = display_list_make(); + let hits_a: HitTree = hit_tree_make(); + let semantics_a: SemanticsTree = semantics_tree_make(); + let display_b: DisplayList = display_list_make(); + let hits_b: HitTree = hit_tree_make(); + let semantics_b: SemanticsTree = semantics_tree_make(); + let spec: SurfaceSpec = action_fixture(720, + SurfaceCanonicalState.selected); + spec.focus_order = 1; + let first: SurfaceBuildResult = surface_emit(spec, + &display_a, &hits_a, &semantics_a); + let second: SurfaceBuildResult = surface_emit(spec, + &display_b, &hits_b, &semantics_b); + expect(state, first.error == SurfaceBuildError.none && + second.error == SurfaceBuildError.none && + first.artifact.evidence_hash != 0 && + first.artifact.evidence_hash == second.artifact.evidence_hash && + display_a.content_hash == display_b.content_hash && + talkback_tree_hash(semantics_a) == talkback_tree_hash(semantics_b), + "identical Surface inputs produce identical component display and semantic evidence"); + semantics_tree_free(&semantics_b); hit_tree_free(&hits_b); + display_list_free(&display_b); + semantics_tree_free(&semantics_a); hit_tree_free(&hits_a); + display_list_free(&display_a); + + let sealed_display: DisplayList = display_list_make(); + let sealed_hits: HitTree = hit_tree_make(); + let sealed_semantics: SemanticsTree = semantics_tree_make(); + _ = display_list_seal(&sealed_display); + let sealed: SurfaceBuildResult = surface_emit(action_fixture(730, + SurfaceCanonicalState.rest), &sealed_display, &sealed_hits, + &sealed_semantics); + expect(state, sealed.error == SurfaceBuildError.display_error && + sealed.display_error == DisplayError.sealed && + sealed_display.ops.len == 0 && sealed_hits.nodes.len == 0 && + sealed_semantics.nodes.len == 0, + "sealed display destinations fail without partial hit or semantic trees"); + semantics_tree_free(&sealed_semantics); + hit_tree_free(&sealed_hits); + display_list_free(&sealed_display); +} + +fn main() i32 { + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + reducer_contract(&state); + focus_modality_contract(&state); + hierarchy_and_spacing_contract(&state); + state_matrix_contract(&state); + visible_treatment_contract(&state); + dynamic_return_lifetime_contract(&state); + capacity_and_atomicity_contract(&state); + invalid_and_deterministic_contract(&state); + _zag_print("Surface contract: pass="); + _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); + _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tests/typography_specimen_contract.zag b/tests/typography_specimen_contract.zag new file mode 100644 index 0000000..56db645 --- /dev/null +++ b/tests/typography_specimen_contract.zag @@ -0,0 +1,592 @@ +@import("../src/showcase/typography_specimen.zag") +@import("../src/render/cpu_raster.zag") + +struct TestState { passed: i32, failed: i32 } + +fn expect(state: *TestState, + condition: i32, name: []u8) void { + if (condition != 0) { + state.*.passed = state.*.passed + 1; + _zag_print("ok "); _zag_println(name); + } else { + state.*.failed = state.*.failed + 1; + _zag_print("FAIL "); _zag_println(name); + } +} + +fn typography_test_rect_equal(left: Rect, right: Rect) i32 { + return (left.x == right.x && left.y == right.y && + left.width == right.width && left.height == right.height) as i32; +} + +fn typography_test_face(path: []u8) OpenTypeFace { + let bytes: []u8 = _zag_read_file(path); + if (bytes.len <= 0) { return opentype_face_empty(); } + let face: OpenTypeFace = opentype_parse(bytes); + _zag_free(_zag_slice_ptr(bytes)); + return face; +} + +fn typography_test_base_spec() TypographySpecimenSpec { + return typography_specimen_spec(node_key_scoped(7300, 4), + rect(0, 0, 1120 * unit_scale(), 720 * unit_scale())); +} + +fn typography_test_structure_contract( + state: *TestState, + artifact: *TypographySpecimenArtifact) void { + expect(state, typography_specimen_valid(artifact) != 0 && + artifact.*.rows.len == 13 && artifact.*.semantics.nodes.len == 40 && + artifact.*.display.ops.len == 130 && + artifact.*.display.resources.resources.len == 26, + "thirteen reusable rows own twenty-six Text paths and forty semantic nodes"); + expect(state, node_key_equal(artifact.*.id, + node_key_scoped(7300, 4)) != 0 && + node_key_equal(artifact.*.semantics.nodes.data[0].id, + artifact.*.id) != 0 && + artifact.*.semantics.nodes.data[0].role == SemanticRole.group && + typography_test_rect_equal( + artifact.*.semantics.nodes.data[0].bounds, + artifact.*.bounds) != 0, + "one stable scoped root ID owns the exact specimen semantic bounds"); + expect(state, artifact.*.outer_padding == + flex_spacing(FlexSpacingToken.large, FlexDensity.standard) && + artifact.*.row_gap == + flex_spacing(FlexSpacingToken.small, FlexDensity.standard) && + artifact.*.row_padding == + flex_spacing(FlexSpacingToken.small, FlexDensity.standard) && + artifact.*.column_gap == + flex_spacing(FlexSpacingToken.large, FlexDensity.standard) && + artifact.*.required_height == + typography_specimen_required_height( + unit_scale(), FlexDensity.standard) && + artifact.*.required_height == 666 * unit_scale() && + artifact.*.structural_minimum_width == 344 * unit_scale() && + artifact.*.required_height < artifact.*.bounds.height, + "all repeated specimen rhythm resolves through named Flex spacing tokens"); + + let index: i32 = 0; + let identity_truth: i32 = 1; + let geometry_truth: i32 = 1; + while (index < artifact.*.rows.len) { + let row: TypographySpecimenRow = artifact.*.rows.data[index]; + if (row.index != index || + node_key_equal(row.row_id, + typography_specimen_row_id(artifact.*.id, index)) == 0 || + node_key_equal(row.label_id, + typography_specimen_label_id(artifact.*.id, index)) == 0 || + node_key_equal(row.sample_id, + typography_specimen_sample_id(artifact.*.id, index)) == 0 || + row.label_evidence_hash == 0 || row.sample_evidence_hash == 0 || + row.sample_path_payload_hash == 0) { + identity_truth = 0; + } + let row_semantic: i32 = semantics_find_index( + artifact.*.semantics, row.row_id); + let label_semantic: i32 = semantics_find_index( + artifact.*.semantics, row.label_id); + let sample_semantic: i32 = semantics_find_index( + artifact.*.semantics, row.sample_id); + if (row_semantic < 0 || label_semantic < 0 || sample_semantic < 0 || + typography_test_rect_equal( + artifact.*.semantics.nodes.data[row_semantic].bounds, + row.bounds) == 0 || + typography_test_rect_equal( + artifact.*.semantics.nodes.data[label_semantic].bounds, + row.label_bounds) == 0 || + typography_test_rect_equal( + artifact.*.semantics.nodes.data[sample_semantic].bounds, + row.sample_bounds) == 0 || + node_key_equal( + artifact.*.semantics.nodes.data[label_semantic].parent, + row.row_id) == 0 || + node_key_equal( + artifact.*.semantics.nodes.data[sample_semantic].parent, + row.row_id) == 0) { + geometry_truth = 0; + } + index = index + 1; + } + expect(state, identity_truth != 0, + "every row label and sample exposes deterministic non-pixel identity and evidence"); + expect(state, geometry_truth != 0, + "Flex geometry is shared exactly by row metadata Text semantics and Talkback"); +} + +fn typography_test_token_contract( + state: *TestState, + artifact: *TypographySpecimenArtifact) void { + let ramp_truth: i32 = 1; + let index: i32 = 0; + while (index < 7) { + let row: TypographySpecimenRow = artifact.*.rows.data[index]; + if (row.section != TypographySpecimenSection.type_ramp || + row.requested_type != index as SemanticTypeToken || + row.resolved_type != row.requested_type || + row.weight != TypographySpecimenWeight.regular || + row.color != SemanticColorToken.text_primary || + row.resolved_font_size != + semantic_type_size(row.resolved_type)) { + ramp_truth = 0; + } + index = index + 1; + } + expect(state, ramp_truth != 0, + "display title heading body label caption and code form the canonical semantic ramp"); + expect(state, + artifact.*.rows.data[7].section == TypographySpecimenSection.weight && + artifact.*.rows.data[7].weight == TypographySpecimenWeight.light && + artifact.*.rows.data[8].weight == TypographySpecimenWeight.regular && + artifact.*.rows.data[9].weight == TypographySpecimenWeight.bold && + artifact.*.rows.data[7].requested_type == SemanticTypeToken.body && + artifact.*.rows.data[8].requested_type == SemanticTypeToken.body && + artifact.*.rows.data[9].requested_type == SemanticTypeToken.body, + "the weight strip changes only the caller-declared face at one type role"); + expect(state, + artifact.*.rows.data[6].requested_type == SemanticTypeToken.code && + artifact.*.rows.data[6].resolved_type == SemanticTypeToken.code && + artifact.*.code_family_routing_available == 0, + "the code size role is present without claiming unavailable font-family routing"); + expect(state, artifact.*.weight_shape_proof == 1 && + artifact.*.light_probe_hash != artifact.*.regular_probe_hash && + artifact.*.light_probe_hash != artifact.*.bold_probe_hash && + artifact.*.regular_probe_hash != artifact.*.bold_probe_hash && + artifact.*.weight_metadata_verified == 0, + "weight proof compares real outline payloads without claiming unparsed OS/2 metadata"); + expect(state, + artifact.*.rows.data[10].section == TypographySpecimenSection.contrast && + artifact.*.rows.data[10].color == SemanticColorToken.text_primary && + artifact.*.rows.data[11].color == SemanticColorToken.text_secondary && + artifact.*.rows.data[12].color == SemanticColorToken.text_disabled && + artifact.*.rows.data[12].disabled == 1, + "primary secondary and disabled contrast are canonical tokens with semantic state"); +} + +fn typography_test_talkback_contract( + state: *TestState, + artifact: *TypographySpecimenArtifact) void { + let session: TalkbackSession = talkback_session_make( + talkback_capabilities()); + let body_id: NodeKey = typography_specimen_sample_id(artifact.*.id, 3); + let disabled_id: NodeKey = typography_specimen_sample_id( + artifact.*.id, 12); + let body: TalkbackResponse = talkback_dispatch(&session, + artifact.*.semantics, artifact.*.bounds, + talkback_request(1, TalkbackCommand.query, body_id, + artifact.*.semantics.revision)); + let disabled: TalkbackResponse = talkback_dispatch(&session, + artifact.*.semantics, artifact.*.bounds, + talkback_request(2, TalkbackCommand.query, disabled_id, + artifact.*.semantics.revision)); + let click: TalkbackResponse = talkback_dispatch(&session, + artifact.*.semantics, artifact.*.bounds, + talkback_request(3, TalkbackCommand.click, body_id, + artifact.*.semantics.revision)); + expect(state, body.status == TalkbackStatus.accepted && + body.resolved_role == SemanticRole.text && body.has_bounds == 1 && + typography_test_rect_equal(body.bounds, + artifact.*.rows.data[3].sample_bounds) != 0, + "Talkback queries the body specimen by stable ID with exact Text geometry"); + expect(state, disabled.status == TalkbackStatus.accepted && + (disabled.state_flags & talkback_state_disabled_bit()) != 0 && + click.status == TalkbackStatus.action_unavailable, + "disabled text is inspectable while decorative specimens expose no fake action"); + talkback_session_free(&session); +} + +fn typography_test_host_parent(tree: *SemanticsTree, + id: NodeKey, bounds: Rect) void { + let parent: SemanticsSpec = semantics_spec(id, semantic_root_key(), + SemanticRole.group, "Typography host"); + parent.has_bounds = 1; + parent.bounds = bounds; + _ = semantics_add(tree, parent); +} + +fn typography_test_host_background(display: *DisplayList, + bounds: Rect) void { + let background: DisplayOp = display_op(DisplayOpKind.fill_rect, + node_key(7599)); + background.bounds = bounds; + background.paint = semantic_color_paint(SemanticColorToken.canvas); + _ = display_list_push(display, background); +} + +fn typography_test_host_contract( + state: *TestState, + artifact: *TypographySpecimenArtifact) void { + let host_id: NodeKey = node_key(7600); + let display: DisplayList = display_list_make(); + typography_test_host_background(&display, artifact.*.bounds); + let semantics: SemanticsTree = semantics_tree_make(); + typography_test_host_parent(&semantics, host_id, artifact.*.bounds); + let artifact_hash: i64 = artifact.*.evidence_hash; + let hosted: TypographySpecimenHostResult = + typography_specimen_contribute_hosted(artifact, + typography_specimen_host_spec(host_id), + &display, &semantics); + let root_index: i32 = semantics_find_index(semantics, artifact.*.id); + let sample_index: i32 = semantics_find_index(semantics, + typography_specimen_sample_id(artifact.*.id, 3)); + expect(state, hosted.error == TypographySpecimenHostError.none && + hosted.display_operations_added == 130 && + hosted.display_resources_added == 26 && + hosted.semantic_nodes_added == 40 && + display.ops.len == 131 && + display.resources.resources.len == 26 && semantics.nodes.len == 41, + "hosted contribution atomically adds the exact immutable display and semantic counts"); + expect(state, root_index >= 0 && sample_index >= 0 && + node_key_equal(semantics.nodes.data[root_index].parent, host_id) != 0 && + node_key_equal(semantics.nodes.data[sample_index].parent, + typography_specimen_row_id(artifact.*.id, 3)) != 0 && + typography_test_rect_equal(semantics.nodes.data[sample_index].bounds, + artifact.*.rows.data[3].sample_bounds) != 0, + "hosting reparents only the specimen root and preserves every child ID and Flex bound"); + let hosted_session: TalkbackSession = talkback_session_make( + talkback_capabilities()); + let hosted_query: TalkbackResponse = talkback_dispatch( + &hosted_session, semantics, artifact.*.bounds, + talkback_request(40, TalkbackCommand.query, + typography_specimen_sample_id(artifact.*.id, 3), + semantics.revision)); + expect(state, hosted_query.status == TalkbackStatus.accepted && + hosted_query.resolved_role == SemanticRole.text && + typography_test_rect_equal(hosted_query.bounds, + artifact.*.rows.data[3].sample_bounds) != 0, + "Talkback resolves hosted typography by the original stable sample ID"); + talkback_session_free(&hosted_session); + let source_resource: RenderResource = + artifact.*.display.resources.resources.data[0]; + let hosted_resource_index: i32 = render_resource_find_index( + &display.resources, source_resource.id); + expect(state, hosted_resource_index >= 0 && + display.resources.resources.data[hosted_resource_index].content_hash == + source_resource.content_hash && + typography_specimen_valid(artifact) != 0 && + artifact.*.evidence_hash == artifact_hash, + "hosting copies resources exactly and leaves the reusable specimen immutable"); + expect(state, display_list_seal(&display) == DisplayError.none && + display_list_verify(&display) == DisplayError.none, + "the caller can continue composition and seal the hosted display normally"); + semantics_tree_free(&semantics); + display_list_free(&display); + + let duplicate_display: DisplayList = display_list_make(); + typography_test_host_background(&duplicate_display, artifact.*.bounds); + let duplicate_semantics: SemanticsTree = semantics_tree_make(); + typography_test_host_parent(&duplicate_semantics, + host_id, artifact.*.bounds); + let duplicate: SemanticsSpec = semantics_spec( + typography_specimen_sample_id(artifact.*.id, 4), host_id, + SemanticRole.text, "Existing collision"); + duplicate.has_bounds = 1; + duplicate.bounds = artifact.*.rows.data[4].sample_bounds; + _ = semantics_add(&duplicate_semantics, duplicate); + let duplicate_ops: i32 = duplicate_display.ops.len; + let duplicate_hash: i64 = duplicate_display.content_hash; + let duplicate_nodes: i32 = duplicate_semantics.nodes.len; + let duplicate_revision: i64 = duplicate_semantics.revision; + let duplicate_result: TypographySpecimenHostResult = + typography_specimen_contribute_hosted(artifact, + typography_specimen_host_spec(host_id), + &duplicate_display, &duplicate_semantics); + expect(state, duplicate_result.error == + TypographySpecimenHostError.duplicate_semantic_id && + duplicate_display.ops.len == duplicate_ops && + duplicate_display.resources.resources.len == 0 && + duplicate_display.content_hash == duplicate_hash && + duplicate_semantics.nodes.len == duplicate_nodes && + duplicate_semantics.revision == duplicate_revision, + "semantic ID collisions reject before either host destination changes"); + semantics_tree_free(&duplicate_semantics); + display_list_free(&duplicate_display); + + let collision_display: DisplayList = display_list_make(); + let collision_spec: RenderResourceSpec = render_resource_spec( + source_resource.id, source_resource.kind, + source_resource.payload.data[0..source_resource.payload.len], + source_resource.format_tag); + collision_spec.revision = source_resource.revision; + collision_spec.width = source_resource.width; + collision_spec.height = source_resource.height; + collision_spec.color_space = source_resource.color_space; + _ = display_list_add_resource(&collision_display, collision_spec); + let collision_semantics: SemanticsTree = semantics_tree_make(); + typography_test_host_parent(&collision_semantics, + host_id, artifact.*.bounds); + let collision_hash: i64 = collision_display.content_hash; + let collision_nodes: i32 = collision_semantics.nodes.len; + let collision_result: TypographySpecimenHostResult = + typography_specimen_contribute_hosted(artifact, + typography_specimen_host_spec(host_id), + &collision_display, &collision_semantics); + expect(state, collision_result.error == + TypographySpecimenHostError.resource_collision && + collision_display.resources.resources.len == 1 && + collision_display.ops.len == 0 && + collision_display.content_hash == collision_hash && + collision_semantics.nodes.len == collision_nodes, + "resource collisions fail before staged display or semantic publication"); + semantics_tree_free(&collision_semantics); + display_list_free(&collision_display); + + let missing_display: DisplayList = display_list_make(); + let missing_semantics: SemanticsTree = semantics_tree_make(); + let missing: TypographySpecimenHostResult = + typography_specimen_contribute_hosted(artifact, + typography_specimen_host_spec(node_key(7699)), + &missing_display, &missing_semantics); + expect(state, missing.error == + TypographySpecimenHostError.missing_semantic_parent && + missing_display.ops.len == 0 && + missing_display.resources.resources.len == 0 && + missing_semantics.nodes.len == 0, + "a missing declared host parent fails with both destinations empty"); + semantics_tree_free(&missing_semantics); + display_list_free(&missing_display); + + let limited_display: DisplayList = + display_list_make_with_resource_limits(1, 1024, 1024); + let limited_semantics: SemanticsTree = semantics_tree_make(); + typography_test_host_parent(&limited_semantics, + host_id, artifact.*.bounds); + let limited: TypographySpecimenHostResult = + typography_specimen_contribute_hosted(artifact, + typography_specimen_host_spec(host_id), + &limited_display, &limited_semantics); + expect(state, limited.error == + TypographySpecimenHostError.invalid_destination && + limited_display.ops.len == 0 && + limited_display.resources.resources.len == 0 && + limited_semantics.nodes.len == 1, + "insufficient host resource capacity fails atomically before mutation"); + semantics_tree_free(&limited_semantics); + display_list_free(&limited_display); +} + +fn typography_test_adaptive_contract( + state: *TestState, + light: *OpenTypeFace, + regular: *OpenTypeFace, + bold: *OpenTypeFace, + baseline: *TypographySpecimenArtifact) void { + let rtl_spec: TypographySpecimenSpec = typography_test_base_spec(); + rtl_spec.direction = LayoutDirection.rtl; + rtl_spec.density = FlexDensity.compact; + let rtl: TypographySpecimenArtifact = typography_specimen_build( + rtl_spec, light, regular, bold); + expect(state, typography_specimen_valid(&rtl) != 0 && + rtl.rtl_layout_applied == 1 && rtl.nominal_ltr_text_only == 1 && + rtl.bidi_shaping_available == 0 && rtl.font_fallback_available == 0 && + rtl.rows.data[0].label_bounds.x > rtl.rows.data[0].sample_bounds.x && + baseline.*.rows.data[0].label_bounds.x < + baseline.*.rows.data[0].sample_bounds.x && + rtl.rows.data[0].label_alignment == TextAlignment.start && + rtl.rows.data[0].sample_alignment == TextAlignment.end, + "RTL mirrors Flex columns and physical alignment while reporting nominal LTR text truth"); + expect(state, rtl.outer_padding == + flex_spacing(FlexSpacingToken.large, FlexDensity.compact) && + rtl.row_gap == + flex_spacing(FlexSpacingToken.small, FlexDensity.compact) && + rtl.outer_padding < baseline.*.outer_padding && + node_key_equal(rtl.rows.data[5].sample_id, + baseline.*.rows.data[5].sample_id) != 0, + "compact density changes named spacing without changing stable identity"); + + let large_spec: TypographySpecimenSpec = typography_test_base_spec(); + large_spec.bounds = rect(0, 0, + 1360 * unit_scale(), 900 * unit_scale()); + large_spec.text_scale = (unit_scale() * 7) / 4; + let large: TypographySpecimenArtifact = typography_specimen_build( + large_spec, light, regular, bold); + expect(state, typography_specimen_valid(&large) != 0 && + large.scale_steps == 2 && + large.continuous_text_scaling_available == 0 && + large.rows.data[0].requested_type == SemanticTypeToken.display && + large.rows.data[0].resolved_type == SemanticTypeToken.display && + large.rows.data[3].requested_type == SemanticTypeToken.body && + large.rows.data[3].resolved_type == SemanticTypeToken.title && + large.rows.data[5].requested_type == SemanticTypeToken.caption && + large.rows.data[5].resolved_type == SemanticTypeToken.body && + large.rows.data[6].requested_type == SemanticTypeToken.code && + large.rows.data[6].resolved_type == SemanticTypeToken.heading && + large.required_height == (814 * unit_scale()) + unit_scale() / 2 && + large.required_height > 720 * unit_scale() && + large.required_height < large.bounds.height && + large.rows.data[3].resolved_font_size > + baseline.*.rows.data[3].resolved_font_size, + "large text uses explicit canonical-role substitution and records the continuous-scale limit"); + expect(state, large.rows.data[3].bounds.height > + baseline.*.rows.data[3].bounds.height && + node_key_equal(large.rows.data[3].sample_id, + baseline.*.rows.data[3].sample_id) != 0, + "large text recomputes Flex row geometry without replacing component identity"); + + typography_specimen_free(&large); + typography_specimen_free(&rtl); +} + +fn typography_test_bounds_contract( + state: *TestState, + light: *OpenTypeFace, + regular: *OpenTypeFace, + bold: *OpenTypeFace) void { + let useful_spec: TypographySpecimenSpec = typography_specimen_spec( + node_key(7350), rect(0, 0, + 705 * unit_scale(), 720 * unit_scale())); + let useful: TypographySpecimenArtifact = typography_specimen_build( + useful_spec, light, regular, bold); + expect(state, typography_specimen_valid(&useful) != 0 && + useful.required_height == 666 * unit_scale(), + "the default DejaVu specimen fits its measured 705 by 720 useful bound"); + + let narrow_spec: TypographySpecimenSpec = typography_specimen_spec( + node_key(7351), rect(0, 0, + 704 * unit_scale(), 720 * unit_scale())); + let narrow: TypographySpecimenArtifact = typography_specimen_build( + narrow_spec, light, regular, bold); + expect(state, narrow.error == TypographySpecimenError.text_error && + narrow.text_error == TextError.overflow && narrow.error_row == 0 && + narrow.evidence_hash == 0, + "one pixel below the default useful width fails on the display sample instead of clipping"); + + let short_spec: TypographySpecimenSpec = typography_specimen_spec( + node_key(7352), rect(0, 0, + 1120 * unit_scale(), 665 * unit_scale())); + let short: TypographySpecimenArtifact = typography_specimen_build( + short_spec, light, regular, bold); + expect(state, short.error == TypographySpecimenError.layout_overflow && + short.rows.len == 0 && short.semantics.nodes.len == 0 && + short.display.ops.len == 0, + "one pixel below the required height fails before semantic or display publication"); + + typography_specimen_free(&short); + typography_specimen_free(&narrow); + typography_specimen_free(&useful); +} + +fn typography_test_determinism_contract( + state: *TestState, + light: *OpenTypeFace, + regular: *OpenTypeFace, + bold: *OpenTypeFace, + first: *TypographySpecimenArtifact) void { + let second: TypographySpecimenArtifact = typography_specimen_build( + typography_test_base_spec(), light, regular, bold); + let first_raster: CpuRasterResult = cpu_rasterize(first.*.display, + 1120, 720); + let second_raster: CpuRasterResult = cpu_rasterize(second.display, + 1120, 720); + let first_hash: i64 = 0; + let second_hash: i64 = 0; + if (first_raster.error == CpuRasterError.none) { + first_hash = cpu_surface_hash(first_raster.surface); + } + if (second_raster.error == CpuRasterError.none) { + second_hash = cpu_surface_hash(second_raster.surface); + } + expect(state, typography_specimen_valid(&second) != 0 && + first.*.evidence_hash == second.evidence_hash && + first.*.display_hash == second.display_hash && + first.*.talkback_hash == second.talkback_hash, + "identical type faces tokens scale and Flex inputs produce identical evidence"); + expect(state, first_hash != 0 && first_hash == second_hash && + first_raster.pixels_touched > 0 && + second_raster.pixels_touched > 0, + "the CPU oracle produces deterministic nonempty typography pixels"); + _zag_print("Typography specimen evidence hash: "); + _zag_print_i64(first.*.evidence_hash); _zag_println(""); + _zag_print("Typography specimen CPU hash: "); + _zag_print_i64(first_hash); _zag_println(""); + cpu_raster_free(&second_raster); + cpu_raster_free(&first_raster); + typography_specimen_free(&second); +} + +fn typography_test_failure_contract( + state: *TestState, + regular: *OpenTypeFace, + bold: *OpenTypeFace) void { + let invalid_face: OpenTypeFace = opentype_face_empty(); + let missing: TypographySpecimenArtifact = typography_specimen_build( + typography_test_base_spec(), &invalid_face, regular, bold); + expect(state, missing.error == TypographySpecimenError.missing_font && + missing.missing_face_index == 0 && missing.rows.len == 0 && + missing.semantics.nodes.len == 0 && missing.display.ops.len == 0 && + missing.weight_shape_proof == 0 && missing.evidence_hash == 0, + "a missing light face fails before any partial display semantics or rows exist"); + + let disguised: TypographySpecimenArtifact = typography_specimen_build( + typography_test_base_spec(), regular, regular, regular); + expect(state, + disguised.error == TypographySpecimenError.weight_not_visible && + disguised.rows.len == 0 && disguised.semantics.nodes.len == 0 && + disguised.display.ops.len == 0 && disguised.weight_shape_proof == 0 && + disguised.light_probe_hash == disguised.regular_probe_hash && + disguised.regular_probe_hash == disguised.bold_probe_hash, + "three identical faces cannot masquerade as a visible weight scale"); + + let small_spec: TypographySpecimenSpec = typography_specimen_spec( + node_key(7400), rect(0, 0, + 1200 * unit_scale(), 200 * unit_scale())); + let overflow: TypographySpecimenArtifact = typography_specimen_build( + small_spec, regular, regular, bold); + expect(state, overflow.error == TypographySpecimenError.layout_overflow && + overflow.rows.len == 0 && overflow.semantics.nodes.len == 0 && + overflow.display.ops.len == 0, + "insufficient vertical space fails as explicit Flex overflow before rendering"); + + let invalid_spec: TypographySpecimenSpec = typography_test_base_spec(); + invalid_spec.text_scale = 0; + let invalid: TypographySpecimenArtifact = typography_specimen_build( + invalid_spec, regular, regular, bold); + expect(state, invalid.error == TypographySpecimenError.invalid_spec && + invalid.rows.len == 0 && invalid.semantics.nodes.len == 0, + "invalid text scale fails before font layout or semantic publication"); + + typography_specimen_free(&invalid); + typography_specimen_free(&overflow); + typography_specimen_free(&disguised); + typography_specimen_free(&missing); + opentype_face_free(&invalid_face); +} + +fn main() i32 { + if (_zag_argc() != 4) { + _zag_eprintln( + "usage: typography_specimen_contract "); + return 2; + } + let state: TestState = TestState{ .passed = 0, .failed = 0 }; + let light: OpenTypeFace = typography_test_face(_zag_arg(1)); + let regular: OpenTypeFace = typography_test_face(_zag_arg(2)); + let bold: OpenTypeFace = typography_test_face(_zag_arg(3)); + expect(&state, light.error == OpenTypeError.none && + regular.error == OpenTypeError.none && bold.error == OpenTypeError.none, + "light regular and bold fixtures parse through Zag-owned OpenType"); + + let artifact: TypographySpecimenArtifact = typography_specimen_build( + typography_test_base_spec(), &light, ®ular, &bold); + typography_test_structure_contract(&state, &artifact); + typography_test_token_contract(&state, &artifact); + typography_test_talkback_contract(&state, &artifact); + typography_test_host_contract(&state, &artifact); + typography_test_adaptive_contract( + &state, &light, ®ular, &bold, &artifact); + typography_test_bounds_contract( + &state, &light, ®ular, &bold); + typography_test_determinism_contract( + &state, &light, ®ular, &bold, &artifact); + typography_test_failure_contract(&state, ®ular, &bold); + + typography_specimen_free(&artifact); + opentype_face_free(&bold); + opentype_face_free(®ular); + opentype_face_free(&light); + _zag_print("Typography specimen contract: pass="); + _zag_print_i64(state.passed as i64); + _zag_print(" fail="); _zag_print_i64(state.failed as i64); + _zag_println(""); + if (state.failed == 0) { return 0; } + return 1; +} diff --git a/tools/check-showcase-conformance.sh b/tools/check-showcase-conformance.sh new file mode 100755 index 0000000..7449419 --- /dev/null +++ b/tools/check-showcase-conformance.sh @@ -0,0 +1,139 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$root" + +fail() { + printf 'showcase conformance check: FAIL: %s\n' "$1" >&2 + exit 1 +} + +require_file() { + [ -f "$1" ] || fail "missing required file: $1" +} + +require_text() { + path=$1 + pattern=$2 + reason=$3 + grep -Fq -- "$pattern" "$path" || fail "$reason" +} + +for path in \ + docs/design/showcase-conformance.md \ + docs/design/semantic-tokens.md \ + docs/components/surface.md \ + docs/components/segmented-control.md \ + docs/components/performance-chart.md \ + docs/showcase/component-state-gallery.md \ + docs/showcase/typography-specimen.md \ + src/design/tokens.zag \ + src/components/surface.zag \ + src/components/segmented_control.zag \ + src/components/performance_chart.zag \ + src/showcase/component_state_gallery.zag \ + src/showcase/typography_specimen.zag \ + src/showcase/linux_preview_composition.zag \ + tests/component_state_gallery_contract.zag \ + tests/typography_specimen_contract.zag; do + require_file "$path" +done + +# Screen and component code may resolve semantic roles, but may not create a +# private color scale. The renderer and token authority are the only places +# where literal channel values belong. +if rg -n 'paint_rgba16[[:space:]]*\(' src/components src/showcase >/tmp/zagkit-showcase-rgba.txt; then + sed -n '1,20p' /tmp/zagkit-showcase-rgba.txt >&2 + fail "component/showcase source contains local RGBA literals" +fi + +require_text src/design/tokens.zag 'enum SemanticColorToken' \ + "semantic color-token authority is missing" +require_text src/design/tokens.zag 'enum SemanticTypeToken { display, title, heading, body, label, caption, code }' \ + "complete visible typography ramp is missing" +require_text src/design/tokens.zag 'enum SemanticElevationToken { base, panel, raised, overlay }' \ + "ordered elevation tiers are missing" +require_text src/design/tokens.zag 'enum ShowcaseCategory { frame_pacing, semantics, motion, input, renderer }' \ + "status/category meaning is not declared" +require_text docs/design/semantic-tokens.md 'Category never implies health, selection, or action.' \ + "category color semantics are not documented" +require_text docs/design/semantic-tokens.md 'It contains no' \ + "read-only status-rail truth is not documented" + +for tier in base panel raised overlay; do + require_text src/showcase/component_state_gallery.zag "SurfaceTier.$tier" \ + "component gallery does not render Surface tier: $tier" +done +for state in REST HOVER FOCUS PRESSED SELECTED LOADING ERROR DISABLED; do + require_text src/showcase/component_state_gallery.zag "\"$state\"" \ + "component gallery does not visibly name canonical state: $state" +done +for primitive in \ + '@import("../components/button.zag")' \ + '@import("../components/navigation_item.zag")' \ + '@import("../components/segmented_control.zag")' \ + '@import("../components/surface.zag")' \ + '@import("../components/text.zag")'; do + require_text src/showcase/component_state_gallery.zag "$primitive" \ + "component gallery is not composed from canonical primitive: $primitive" +done + +require_text src/components/performance_chart.zag \ + 'enum PerformanceChartContentState { ready, loading, empty, error }' \ + "chart does not expose ready/loading/empty/error states" +for anatomy in x_axis_name x_axis_unit y_axis_name y_axis_unit \ + baseline_value deadline_value; do + require_text src/components/performance_chart.zag "$anatomy" \ + "chart contract is missing anatomy: $anatomy" +done +require_text src/components/performance_chart.zag 'SemanticRole.table' \ + "chart has no semantic table equivalent" +require_text src/components/performance_chart.zag \ + 'PerformanceChartLegendInteractionPolicy { read_only, actionable }' \ + "chart legend cannot distinguish read-only evidence from actions" + +require_text src/components/segmented_control.zag 'selected_index' \ + "segmented control has no retained selection model" +require_text src/components/segmented_control.zag 'roving_index' \ + "segmented control has no roving keyboard target" +require_text src/components/segmented_control.zag 'focus_visible' \ + "segmented control cannot distinguish actual and visible focus" + +for primitive in performance_chart segmented_control surface text; do + require_text src/showcase/linux_preview_composition.zag \ + "@import(\"../components/$primitive.zag\")" \ + "native showcase composition does not import canonical $primitive" +done +require_text src/showcase/linux_preview_composition.zag \ + '@import("typography_specimen.zag")' \ + "native showcase does not host the typography specimen" +require_text src/showcase/linux_preview_composition.zag \ + 'struct LinuxPreviewInteractionState' \ + "native showcase has no shared NodeKey interaction state" +require_text tools/test-linux-interaction.sh \ + 'INSPECT_PRESS_PATTERN=' \ + "native gate does not prove a real pressed component state" +require_text tools/test-linux-interaction.sh \ + 'TYPOGRAPHY_FOCUS_PATTERN=' \ + "native gate does not prove keyboard-visible navigation focus" +if grep -Fq '@import("linux_preview_scene.zag")' \ + src/showcase/linux_preview_composition.zag examples/linux_preview.zag \ + tools/render-linux-preview-reference.zag; then + fail "native or CPU-reference entrypoint still imports the legacy one-off scene" +fi + +require_text docs/design/showcase-conformance.md \ + 'Rendering, semantics, hit testing, and Talkback consume one shared Flex' \ + "single placement-authority rule is missing" +require_text docs/design/showcase-conformance.md \ + 'A translucent rounded rectangle or' \ + "showcase contract no longer rejects fake liquid-glass claims" +require_text docs/design/showcase-conformance.md \ + 'soft multi-lobe shadows' \ + "showcase contract no longer requires modern shadow composition" +require_text docs/design/showcase-conformance.md \ + 'Screenshot review is necessary but insufficient.' \ + "showcase contract no longer distinguishes screenshots from QA" + +printf 'showcase conformance check: PASS (static system invariants only)\n' diff --git a/tools/test-component-state-gallery.sh b/tools/test-component-state-gallery.sh new file mode 100755 index 0000000..f0f4cac --- /dev/null +++ b/tools/test-component-state-gallery.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +znc=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} + +if [ "$#" -gt 1 ]; then + printf '%s\n' 'usage: test-component-state-gallery.sh [font.ttf]' >&2 + exit 2 +fi + +font=${ZAGKIT_COMPONENT_GALLERY_FONT:-${1:-/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf}} + +if [ ! -x "$znc" ]; then + printf 'component state gallery contract: FAIL: Zag compiler not executable: %s\n' \ + "$znc" >&2 + exit 2 +fi + +if [ ! -f "$font" ]; then + printf 'component state gallery contract: FAIL: required font missing: %s\n' \ + "$font" >&2 + exit 2 +fi + +binary=$(mktemp /tmp/zagkit-test-component-state-gallery.XXXXXX) +cleanup() { rm -f -- "$binary"; } +trap cleanup EXIT + +"$znc" "$root/tests/component_state_gallery_contract.zag" --no-zagd \ + --analyze-strict --no-foreground-cache -o "$binary" +"$binary" "$font" diff --git a/tools/test-linux-interaction.sh b/tools/test-linux-interaction.sh new file mode 100755 index 0000000..c688b1c --- /dev/null +++ b/tools/test-linux-interaction.sh @@ -0,0 +1,836 @@ +#!/usr/bin/env bash +set -euo pipefail +set -f + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +COMPILER=${ZAG_BIN:-${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc}} +WINDOW_TITLE='Zagkit — Linux Preview' +EVIDENCE_DIR=${ZAGKIT_INTERACTION_EVIDENCE_DIR:-} +IDLE_MAX_CPU_TICKS=2 +POLL_DELAY=0.05 +POLL_ATTEMPTS=100 + +TMP_ROOT= +PREVIEW_PID= +PREVIEW_START_TICKS= +PREVIEW_WAITED=0 +BUILD_LOG= +RUNTIME_LOG= +METADATA= +BEFORE_CAPTURE= +HOVER_CAPTURE= +PRESS_CAPTURE= +DISABLED_READY_CAPTURE= +CLICK_CAPTURE= +INSPECTOR_CAPTURE= +SELECT_CAPTURE= +KEYBOARD_CAPTURE= +GENERAL_FOCUS_CAPTURE= +TYPOGRAPHY_CAPTURE= +SMALL_CAPTURE= +RESIZED_CAPTURE= + +usage() { + printf '%s\n' \ + 'Usage: tools/test-linux-interaction.sh [--evidence-dir ]' \ + '' \ + 'Builds and drives the native Linux X11 preview. ZAG_BIN takes precedence' \ + 'over ZNC. The one-second idle allowance is a strict two CPU ticks.' +} + +fail() { + printf 'linux interaction test: FAIL: %s\n' "$1" >&2 + if [ -n "$RUNTIME_LOG" ] && [ -s "$RUNTIME_LOG" ]; then + printf 'linux interaction test: runtime log tail follows\n' >&2 + tail -n 30 "$RUNTIME_LOG" >&2 || true + fi + exit 1 +} + +proc_stat_tail() { + local target_pid=$1 + local stat_line stat_tail + IFS= read -r stat_line < "/proc/$target_pid/stat" || return 1 + stat_tail=${stat_line##*) } + [ "$stat_tail" != "$stat_line" ] || return 1 + printf '%s\n' "$stat_tail" +} + +proc_start_ticks() { + local stat_tail + stat_tail=$(proc_stat_tail "$1") || return 1 + set -- $stat_tail + [ "$#" -ge 20 ] || return 1 + printf '%s\n' "${20}" +} + +proc_cpu_ticks() { + local stat_tail + stat_tail=$(proc_stat_tail "$1") || return 1 + set -- $stat_tail + [ "$#" -ge 13 ] || return 1 + printf '%s\n' "$(( ${12} + ${13} ))" +} + +proc_state() { + local stat_tail + stat_tail=$(proc_stat_tail "$1") || return 1 + set -- $stat_tail + [ "$#" -ge 1 ] || return 1 + printf '%s\n' "$1" +} + +preview_process_matches() { + local current_start + [ -n "$PREVIEW_PID" ] && [ -n "$PREVIEW_START_TICKS" ] || return 1 + current_start=$(proc_start_ticks "$PREVIEW_PID" 2>/dev/null) || return 1 + [ "$current_start" = "$PREVIEW_START_TICKS" ] +} + +preview_process_running() { + local current_state + preview_process_matches || return 1 + current_state=$(proc_state "$PREVIEW_PID" 2>/dev/null) || return 1 + [ "$current_state" != Z ] +} + +publish_evidence() { + local source_path target_name + [ -n "$EVIDENCE_DIR" ] || return 0 + mkdir -p -- "$EVIDENCE_DIR" || return 1 + for source_path in "$BUILD_LOG" "$RUNTIME_LOG" "$METADATA" \ + "$BEFORE_CAPTURE" "$HOVER_CAPTURE" "$PRESS_CAPTURE" \ + "$DISABLED_READY_CAPTURE" "$CLICK_CAPTURE" \ + "$INSPECTOR_CAPTURE" "$SELECT_CAPTURE" "$KEYBOARD_CAPTURE" "$SMALL_CAPTURE" \ + "$GENERAL_FOCUS_CAPTURE" "$TYPOGRAPHY_CAPTURE" "$RESIZED_CAPTURE"; do + [ -n "$source_path" ] && [ -f "$source_path" ] || continue + case "$source_path" in + "$BUILD_LOG") target_name=linux-interaction-build.log ;; + "$RUNTIME_LOG") target_name=linux-interaction-runtime.log ;; + "$METADATA") target_name=linux-interaction-metadata.txt ;; + "$BEFORE_CAPTURE") target_name=linux-interaction-before.png ;; + "$HOVER_CAPTURE") target_name=linux-interaction-inspect-hover.png ;; + "$PRESS_CAPTURE") target_name=linux-interaction-inspect-pressed.png ;; + "$DISABLED_READY_CAPTURE") target_name=linux-interaction-disabled-ready.png ;; + "$CLICK_CAPTURE") target_name=linux-interaction-after-click.png ;; + "$INSPECTOR_CAPTURE") target_name=linux-interaction-token-inspector.png ;; + "$SELECT_CAPTURE") target_name=linux-interaction-after-select.png ;; + "$KEYBOARD_CAPTURE") target_name=linux-interaction-keyboard-focus.png ;; + "$GENERAL_FOCUS_CAPTURE") target_name=linux-interaction-navigation-focus.png ;; + "$TYPOGRAPHY_CAPTURE") target_name=linux-interaction-typography.png ;; + "$SMALL_CAPTURE") target_name=linux-interaction-unsupported-size.png ;; + "$RESIZED_CAPTURE") target_name=linux-interaction-resized.png ;; + *) continue ;; + esac + cp -f -- "$source_path" "$EVIDENCE_DIR/$target_name" || return 1 + done +} + +cleanup() { + local original_status=$? + local attempt + trap - INT TERM EXIT + if [ "$PREVIEW_WAITED" -eq 0 ] && preview_process_matches; then + kill -TERM "$PREVIEW_PID" 2>/dev/null || true + for ((attempt = 0; attempt < 20; attempt++)); do + preview_process_running || break + sleep "$POLL_DELAY" + done + if preview_process_running; then + kill -KILL "$PREVIEW_PID" 2>/dev/null || true + fi + wait "$PREVIEW_PID" 2>/dev/null || true + PREVIEW_WAITED=1 + fi + if ! publish_evidence; then + printf 'linux interaction test: warning: evidence copy failed: %s\n' \ + "$EVIDENCE_DIR" >&2 + if [ "$original_status" -eq 0 ]; then original_status=1; fi + fi + if [ -n "$TMP_ROOT" ] && [ -d "$TMP_ROOT" ]; then + rm -rf -- "$TMP_ROOT" + fi + exit "$original_status" +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --evidence-dir) + [ "$#" -ge 2 ] || { usage >&2; exit 2; } + EVIDENCE_DIR=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'linux interaction test: unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +[ -n "${DISPLAY:-}" ] || fail 'DISPLAY is unavailable' +for required_tool in xdotool import file sha256sum; do + command -v "$required_tool" >/dev/null 2>&1 || + fail "required tool is unavailable: $required_tool" +done +[ -x "$COMPILER" ] || fail "compiler is not executable: $COMPILER" + +FONT_LIGHT_FILE=${ZAGKIT_FONT_LIGHT_FILE:-} +FONT_FILE=${ZAGKIT_FONT_FILE:-} +FONT_BOLD_FILE=${ZAGKIT_FONT_BOLD_FILE:-} +if [ -z "$FONT_LIGHT_FILE" ] || [ -z "$FONT_FILE" ] || + [ -z "$FONT_BOLD_FILE" ]; then + command -v fc-match >/dev/null 2>&1 || + fail 'font weight paths are unset and fc-match is unavailable' + FONT_LIGHT_FILE=$(fc-match -f '%{file}\n' 'Fira Sans:style=Light') + FONT_FILE=$(fc-match -f '%{file}\n' 'Fira Sans:style=Regular') + FONT_BOLD_FILE=$(fc-match -f '%{file}\n' 'Fira Sans:style=Bold') +fi +[ -f "$FONT_LIGHT_FILE" ] || + fail "light font file is unavailable: $FONT_LIGHT_FILE" +[ -f "$FONT_FILE" ] || fail "regular font file is unavailable: $FONT_FILE" +[ -f "$FONT_BOLD_FILE" ] || + fail "bold font file is unavailable: $FONT_BOLD_FILE" +cmp -s -- "$FONT_LIGHT_FILE" "$FONT_FILE" && + fail 'light and regular font weights resolve to identical bytes' +cmp -s -- "$FONT_FILE" "$FONT_BOLD_FILE" && + fail 'regular and bold font weights resolve to identical bytes' +cmp -s -- "$FONT_LIGHT_FILE" "$FONT_BOLD_FILE" && + fail 'light and bold font weights resolve to identical bytes' + +TMP_ROOT=$(mktemp -d /tmp/zagkit-linux-interaction.XXXXXX) +PREVIEW_BINARY="$TMP_ROOT/linux-preview" +BUILD_LOG="$TMP_ROOT/build.log" +RUNTIME_LOG="$TMP_ROOT/runtime.log" +METADATA="$TMP_ROOT/metadata.txt" +BEFORE_CAPTURE="$TMP_ROOT/before.png" +HOVER_CAPTURE="$TMP_ROOT/inspect-hover.png" +PRESS_CAPTURE="$TMP_ROOT/inspect-pressed.png" +DISABLED_READY_CAPTURE="$TMP_ROOT/disabled-ready.png" +CLICK_CAPTURE="$TMP_ROOT/after-click.png" +INSPECTOR_CAPTURE="$TMP_ROOT/token-inspector.png" +SELECT_CAPTURE="$TMP_ROOT/after-select.png" +KEYBOARD_CAPTURE="$TMP_ROOT/keyboard-focus.png" +GENERAL_FOCUS_CAPTURE="$TMP_ROOT/navigation-focus.png" +TYPOGRAPHY_CAPTURE="$TMP_ROOT/typography.png" +SMALL_CAPTURE="$TMP_ROOT/unsupported-size.png" +RESIZED_CAPTURE="$TMP_ROOT/resized.png" + +trap 'exit 130' INT +trap 'exit 143' TERM +trap cleanup EXIT + +record_metadata() { + printf '%s=%s\n' "$1" "$2" >> "$METADATA" +} + +list_exact_title_windows() { + local candidate candidate_title + while IFS= read -r candidate; do + case "$candidate" in ''|*[!0-9]*) continue ;; esac + candidate_title=$(xdotool getwindowname "$candidate" 2>/dev/null || true) + if [ "$candidate_title" = "$WINDOW_TITLE" ]; then + printf '%s\n' "$candidate" + fi + done < <(xdotool search --name 'Zagkit' 2>/dev/null || true) +} + +window_geometry() { + local target_window=$1 + local geometry_line geometry_key geometry_value + local geometry_width= geometry_height= + while IFS= read -r geometry_line; do + geometry_key=${geometry_line%%=*} + geometry_value=${geometry_line#*=} + case "$geometry_key" in + WIDTH) geometry_width=$geometry_value ;; + HEIGHT) geometry_height=$geometry_value ;; + esac + done < <(xdotool getwindowgeometry --shell "$target_window" 2>/dev/null || true) + case "$geometry_width" in ''|*[!0-9]*) return 1 ;; esac + case "$geometry_height" in ''|*[!0-9]*) return 1 ;; esac + [ "$geometry_width" -gt 0 ] && [ "$geometry_height" -gt 0 ] || return 1 + printf '%s %s\n' "$geometry_width" "$geometry_height" +} + +capture_window() { + local target_window=$1 + local output_path=$2 + import -window "$target_window" "$output_path" >/dev/null 2>&1 || return 1 + [ -s "$output_path" ] || return 1 + case $(file -b "$output_path") in + *'PNG image data'*) return 0 ;; + *) return 1 ;; + esac +} + +capture_hash() { + local digest ignored + read -r digest ignored < <(sha256sum -- "$1") || return 1 + case "$digest" in + *[!0-9a-f]*) return 1 ;; + esac + [ "${#digest}" -eq 64 ] || return 1 + printf '%s\n' "$digest" +} + +wait_target_bounds() { + local target_id=$1 + local target_line attempt + for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || return 1 + target_line=$(grep -F \ + "zagkit: talkback pixel-fallback target-id=$target_id " \ + "$RUNTIME_LOG" | tail -n 1 || true) + if [[ $target_line =~ x=([0-9]+)[[:space:]]y=([0-9]+)[[:space:]]width=([0-9]+)[[:space:]]height=([0-9]+)[[:space:]]scale=1/1[[:space:]]enabled=([01]) ]]; then + printf '%s %s %s %s %s\n' \ + "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" \ + "${BASH_REMATCH[3]}" "${BASH_REMATCH[4]}" \ + "${BASH_REMATCH[5]}" + return 0 + fi + sleep "$POLL_DELAY" + done + return 1 +} + +mapfile -t PREEXISTING_WINDOWS < <(list_exact_title_windows) +if [ "${#PREEXISTING_WINDOWS[@]}" -ne 0 ]; then + fail "preexisting exact-title window is present: ${PREEXISTING_WINDOWS[*]}" +fi + +printf 'linux interaction test: building native preview with %s\n' "$COMPILER" +if ! "$COMPILER" "$ROOT_DIR/examples/linux_preview.zag" --dynamic \ + --needed libX11.so.6 --no-zagd --analyze-strict --no-foreground-cache \ + -o "$PREVIEW_BINARY" >"$BUILD_LOG" 2>&1; then + printf 'linux interaction test: compiler log follows\n' >&2 + tail -n 40 "$BUILD_LOG" >&2 || true + fail 'native preview build failed' +fi +[ -x "$PREVIEW_BINARY" ] || fail 'native preview build produced no executable' + +mapfile -t PRELAUNCH_WINDOWS < <(list_exact_title_windows) +if [ "${#PRELAUNCH_WINDOWS[@]}" -ne 0 ]; then + fail "exact-title window appeared before this launch: ${PRELAUNCH_WINDOWS[*]}" +fi + +record_metadata compiler "$COMPILER" +record_metadata display "$DISPLAY" +record_metadata font "$FONT_FILE" +record_metadata font_sha256 "$(capture_hash "$FONT_FILE")" +record_metadata font_light "$FONT_LIGHT_FILE" +record_metadata font_light_sha256 "$(capture_hash "$FONT_LIGHT_FILE")" +record_metadata font_bold "$FONT_BOLD_FILE" +record_metadata font_bold_sha256 "$(capture_hash "$FONT_BOLD_FILE")" +record_metadata window_title "$WINDOW_TITLE" +record_metadata idle_max_cpu_ticks "$IDLE_MAX_CPU_TICKS" + +ZAGKIT_INPUT_TRACE=1 ZAGKIT_FONT_LIGHT_FILE="$FONT_LIGHT_FILE" \ + ZAGKIT_FONT_FILE="$FONT_FILE" ZAGKIT_FONT_BOLD_FILE="$FONT_BOLD_FILE" \ + "$PREVIEW_BINARY" >"$RUNTIME_LOG" 2>&1 & +PREVIEW_PID=$! +PREVIEW_START_TICKS=$(proc_start_ticks "$PREVIEW_PID") || + fail 'launched preview disappeared before its process identity was recorded' +record_metadata preview_pid "$PREVIEW_PID" +record_metadata preview_start_ticks "$PREVIEW_START_TICKS" + +WINDOW_ID= +for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || fail 'launched preview exited before mapping its window' + mapfile -t EXACT_WINDOWS < <(list_exact_title_windows) + if [ "${#EXACT_WINDOWS[@]}" -gt 1 ]; then + fail "exact-title window association is ambiguous: ${EXACT_WINDOWS[*]}" + fi + if [ "${#EXACT_WINDOWS[@]}" -eq 1 ]; then + WINDOW_ID=${EXACT_WINDOWS[0]} + break + fi + sleep "$POLL_DELAY" +done +[ -n "$WINDOW_ID" ] || fail 'exact-title window did not appear before timeout' + +WINDOW_PID_PROPERTY=$(xdotool getwindowpid "$WINDOW_ID" 2>/dev/null || true) +if [ -n "$WINDOW_PID_PROPERTY" ]; then + case "$WINDOW_PID_PROPERTY" in + *[!0-9]*) fail "window exposes an invalid PID property: $WINDOW_PID_PROPERTY" ;; + esac + [ "$WINDOW_PID_PROPERTY" = "$PREVIEW_PID" ] || + fail "window PID $WINDOW_PID_PROPERTY does not match launch PID $PREVIEW_PID" +else + WINDOW_PID_PROPERTY=unavailable +fi +record_metadata window_id "$WINDOW_ID" +record_metadata window_pid_property "$WINDOW_PID_PROPERTY" +record_metadata window_association prelaunch-empty-plus-unique-exact-title +printf 'linux interaction test: associated pid=%s window=%s (WM_PID=%s)\n' \ + "$PREVIEW_PID" "$WINDOW_ID" "$WINDOW_PID_PROPERTY" + +read -r INITIAL_WIDTH INITIAL_HEIGHT < <(window_geometry "$WINDOW_ID") || + fail 'initial window geometry is unavailable' +record_metadata initial_geometry "${INITIAL_WIDTH}x${INITIAL_HEIGHT}" +xdotool mousemove --window "$WINDOW_ID" 1 1 || + fail 'initial pointer normalization failed' +capture_window "$WINDOW_ID" "$BEFORE_CAPTURE" || fail 'initial screenshot failed' +BEFORE_FILE=$(file -b "$BEFORE_CAPTURE") +case "$BEFORE_FILE" in + *"$INITIAL_WIDTH x $INITIAL_HEIGHT"*) ;; + *) fail "initial screenshot dimensions disagree with geometry: $BEFORE_FILE" ;; +esac +BEFORE_HASH=$(capture_hash "$BEFORE_CAPTURE") || fail 'initial screenshot hash failed' +record_metadata before_sha256 "$BEFORE_HASH" + +# The Inspect control must toggle a real retained overlay. Resolve the Button +# by stable ID, open the canonical overlay Surface, capture it, then close it +# before exercising the remaining interaction state machine. +INSPECT_ID=20302 +read -r INSPECT_X INSPECT_Y INSPECT_WIDTH INSPECT_HEIGHT INSPECT_ENABLED \ + < <(wait_target_bounds "$INSPECT_ID") || + fail "stable target bounds unavailable for ID $INSPECT_ID" +[ "$INSPECT_ENABLED" -eq 1 ] || + fail "token inspector target ID $INSPECT_ID is not actionable" +INSPECT_CLICK_X=$((INSPECT_X + INSPECT_WIDTH / 2)) +INSPECT_CLICK_Y=$((INSPECT_Y + INSPECT_HEIGHT / 2)) +record_metadata inspector_target_id "$INSPECT_ID" +record_metadata inspector_target_bounds \ + "${INSPECT_X},${INSPECT_Y},${INSPECT_WIDTH},${INSPECT_HEIGHT}" +record_metadata inspector_target_origin talkback-id-to-recorded-pixel-fallback +xdotool mousemove --window "$WINDOW_ID" \ + "$INSPECT_CLICK_X" "$INSPECT_CLICK_Y" || + fail 'token inspector hover injection failed' +INSPECT_HOVER_PATTERN="hovered-id=$INSPECT_ID pressed-id=-1 focused-id=-1 general-focus-visible=0" +INSPECT_HOVER_SEEN=0 +for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || fail 'preview exited while waiting for Inspect hover' + if grep -Fq "$INSPECT_HOVER_PATTERN" "$RUNTIME_LOG"; then + INSPECT_HOVER_SEEN=1 + break + fi + sleep "$POLL_DELAY" +done +[ "$INSPECT_HOVER_SEEN" -eq 1 ] || + fail 'Inspect did not publish retained hover state' +capture_window "$WINDOW_ID" "$HOVER_CAPTURE" || + fail 'Inspect hover screenshot failed' +HOVER_HASH=$(capture_hash "$HOVER_CAPTURE") || + fail 'Inspect hover screenshot hash failed' +[ "$HOVER_HASH" != "$BEFORE_HASH" ] || + fail 'Inspect hover did not change presented pixels' +record_metadata inspector_hover_trace "$INSPECT_HOVER_PATTERN" +record_metadata inspector_hover_sha256 "$HOVER_HASH" + +xdotool mousedown --window "$WINDOW_ID" 1 || + fail 'token inspector press injection failed' +INSPECT_PRESS_PATTERN="hovered-id=$INSPECT_ID pressed-id=$INSPECT_ID focused-id=$INSPECT_ID general-focus-visible=0" +INSPECT_PRESS_SEEN=0 +for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || fail 'preview exited while waiting for Inspect press' + if grep -Fq "$INSPECT_PRESS_PATTERN" "$RUNTIME_LOG"; then + INSPECT_PRESS_SEEN=1 + break + fi + sleep "$POLL_DELAY" +done +[ "$INSPECT_PRESS_SEEN" -eq 1 ] || + fail 'Inspect did not publish retained pressed and pointer-focus state' +capture_window "$WINDOW_ID" "$PRESS_CAPTURE" || + fail 'Inspect pressed screenshot failed' +PRESS_HASH=$(capture_hash "$PRESS_CAPTURE") || + fail 'Inspect pressed screenshot hash failed' +[ "$PRESS_HASH" != "$HOVER_HASH" ] || + fail 'Inspect pressed state did not change presented pixels' +record_metadata inspector_pressed_trace "$INSPECT_PRESS_PATTERN" +record_metadata inspector_pressed_sha256 "$PRESS_HASH" + +xdotool mouseup --window "$WINDOW_ID" 1 || + fail 'token inspector release injection failed' +INSPECT_OPEN_PATTERN='zagkit: token inspector open=1' +INSPECT_FRAME_PATTERN='token-inspector-open=1 hovered-id=20302 pressed-id=-1 focused-id=20302 general-focus-visible=0 evidence-hash=' +INSPECT_OPEN_SEEN=0 +for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || fail 'preview exited while opening token inspector' + if grep -Fq "$INSPECT_OPEN_PATTERN" "$RUNTIME_LOG" && + grep -Fq "$INSPECT_FRAME_PATTERN" "$RUNTIME_LOG"; then + INSPECT_OPEN_SEEN=1 + break + fi + sleep "$POLL_DELAY" +done +[ "$INSPECT_OPEN_SEEN" -eq 1 ] || + fail 'token inspector did not publish retained open state' +capture_window "$WINDOW_ID" "$INSPECTOR_CAPTURE" || + fail 'token inspector screenshot failed' +INSPECTOR_HASH=$(capture_hash "$INSPECTOR_CAPTURE") || + fail 'token inspector screenshot hash failed' +[ "$INSPECTOR_HASH" != "$BEFORE_HASH" ] || + fail 'token inspector state did not change presented pixels' +[ "$INSPECTOR_HASH" != "$PRESS_HASH" ] || + fail 'released inspector overlay is indistinguishable from pressed state' +record_metadata inspector_sha256 "$INSPECTOR_HASH" +xdotool click --window "$WINDOW_ID" 1 || + fail 'token inspector close injection failed' +INSPECT_CLOSE_PATTERN='zagkit: token inspector open=0' +INSPECT_CLOSE_SEEN=0 +for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || fail 'preview exited while closing token inspector' + if grep -Fq "$INSPECT_CLOSE_PATTERN" "$RUNTIME_LOG"; then + INSPECT_CLOSE_SEEN=1 + break + fi + sleep "$POLL_DELAY" +done +[ "$INSPECT_CLOSE_SEEN" -eq 1 ] || + fail 'token inspector did not publish retained closed state' + +# Initial selection is Overview (index 0). Components is intentionally disabled +# until its real gallery route is composed. Resolve its retained bounds by +# stable ID, then record the explicit scale-aware pixel fallback used by +# xdotool. The test owns no second coordinate table. +COMPONENTS_ID=20201 +read -r TARGET_X TARGET_Y TARGET_WIDTH TARGET_HEIGHT TARGET_ENABLED \ + < <(wait_target_bounds "$COMPONENTS_ID") || + fail "stable target bounds unavailable for ID $COMPONENTS_ID" +[ "$TARGET_ENABLED" -eq 0 ] || + fail "Components target ID $COMPONENTS_ID unexpectedly became actionable" +CLICK_X=$((TARGET_X + TARGET_WIDTH / 2)) +CLICK_Y=$((TARGET_Y + TARGET_HEIGHT / 2)) +[ "$CLICK_X" -gt 0 ] && [ "$CLICK_Y" -gt 0 ] || + fail 'resolved disabled-navigation target has invalid geometry' +record_metadata disabled_target_id "$COMPONENTS_ID" +record_metadata disabled_target_bounds \ + "${TARGET_X},${TARGET_Y},${TARGET_WIDTH},${TARGET_HEIGHT}" +record_metadata disabled_target_origin talkback-id-to-recorded-pixel-fallback +record_metadata click_relative "${CLICK_X},${CLICK_Y}" +xdotool mousemove --window "$WINDOW_ID" "$CLICK_X" "$CLICK_Y" || + fail 'disabled-navigation pointer move failed' +capture_window "$WINDOW_ID" "$DISABLED_READY_CAPTURE" || + fail 'disabled-navigation settled screenshot failed' +DISABLED_READY_HASH=$(capture_hash "$DISABLED_READY_CAPTURE") || + fail 'disabled-navigation settled screenshot hash failed' +record_metadata disabled_ready_sha256 "$DISABLED_READY_HASH" +xdotool click --window "$WINDOW_ID" 1 || + fail 'left-navigation click injection failed' + +TRACE_PATTERN="zagkit: pointer down x=$CLICK_X y=$CLICK_Y button=1" +UNAVAILABLE_PATTERN='zagkit: navigation unavailable route=Components' +TRACE_SEEN=0 +for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || fail 'preview exited while waiting for input trace' + if grep -Fq "$TRACE_PATTERN" "$RUNTIME_LOG" && + grep -Fq "$UNAVAILABLE_PATTERN" "$RUNTIME_LOG"; then + TRACE_SEEN=1 + break + fi + sleep "$POLL_DELAY" +done +[ "$TRACE_SEEN" -eq 1 ] || + fail "missing pointer or unavailable-route trace: $TRACE_PATTERN" +record_metadata pointer_trace "$TRACE_PATTERN" +record_metadata navigation_unavailable Components + +capture_window "$WINDOW_ID" "$CLICK_CAPTURE" || + fail 'disabled-navigation screenshot failed' +CLICK_HASH=$(capture_hash "$CLICK_CAPTURE") || + fail 'disabled-navigation screenshot hash failed' +[ "$CLICK_HASH" = "$DISABLED_READY_HASH" ] || + fail 'disabled navigation changed visible content or selection' +record_metadata after_click_sha256 "$CLICK_HASH" + +# Activate Motion through the same stable-ID fallback and prove that the +# retained SegmentedControl changes the presented composition. +MOTION_ID=20402 +read -r MOTION_X MOTION_Y MOTION_WIDTH MOTION_HEIGHT MOTION_ENABLED \ + < <(wait_target_bounds "$MOTION_ID") || + fail "stable target bounds unavailable for ID $MOTION_ID" +[ "$MOTION_ENABLED" -eq 1 ] || + fail "Motion segment target ID $MOTION_ID is not actionable" +MOTION_CLICK_X=$((MOTION_X + MOTION_WIDTH / 2)) +MOTION_CLICK_Y=$((MOTION_Y + MOTION_HEIGHT / 2)) +record_metadata selected_target_id "$MOTION_ID" +record_metadata selected_target_bounds \ + "${MOTION_X},${MOTION_Y},${MOTION_WIDTH},${MOTION_HEIGHT}" +record_metadata selected_target_origin talkback-id-to-recorded-pixel-fallback +xdotool mousemove --window "$WINDOW_ID" \ + "$MOTION_CLICK_X" "$MOTION_CLICK_Y" click 1 || + fail 'Motion segment click injection failed' + +SELECTION_PATTERN="zagkit: segment selection target-id=$MOTION_ID selected-index=2" +SELECTED_FRAME_PATTERN='zagkit: frame selected-segment=2 roving-segment=2 has-focus=1 focus-visible=0 hovered-segment=2 token-inspector-open=0 hovered-id=-1 pressed-id=-1 focused-id=-1 general-focus-visible=0 evidence-hash=' +SELECTION_SEEN=0 +for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || fail 'preview exited while waiting for segment selection' + if grep -Fq "$SELECTION_PATTERN" "$RUNTIME_LOG" && + grep -Fq "$SELECTED_FRAME_PATTERN" "$RUNTIME_LOG"; then + SELECTION_SEEN=1 + break + fi + sleep "$POLL_DELAY" +done +[ "$SELECTION_SEEN" -eq 1 ] || + fail "missing stable segment selection trace: $SELECTION_PATTERN" +record_metadata selected_trace "$SELECTION_PATTERN" +record_metadata selected_frame_trace "$SELECTED_FRAME_PATTERN" +capture_window "$WINDOW_ID" "$SELECT_CAPTURE" || + fail 'selected-segment screenshot failed' +SELECT_HASH=$(capture_hash "$SELECT_CAPTURE") || + fail 'selected-segment screenshot hash failed' +[ "$SELECT_HASH" != "$CLICK_HASH" ] || + fail 'selected segment did not change the presented composition' +record_metadata after_select_sha256 "$SELECT_HASH" + +# Continue from the real pointer-focused segment. Keyboard navigation must +# skip unavailable Render, wrap to State, and make keyboard modality visible. +STATE_ID=20400 +xdotool key --window "$WINDOW_ID" Right || + fail 'keyboard segment navigation injection failed' +KEY_SELECTION_PATTERN="zagkit: segment selection target-id=$STATE_ID selected-index=0" +KEY_FRAME_PATTERN='zagkit: frame selected-segment=0 roving-segment=0 has-focus=1 focus-visible=1 hovered-segment=2 token-inspector-open=0 hovered-id=-1 pressed-id=-1 focused-id=-1 general-focus-visible=0 evidence-hash=' +KEYBOARD_SEEN=0 +for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || fail 'preview exited while waiting for keyboard focus state' + if grep -Fq "$KEY_SELECTION_PATTERN" "$RUNTIME_LOG" && + grep -Fq "$KEY_FRAME_PATTERN" "$RUNTIME_LOG"; then + KEYBOARD_SEEN=1 + break + fi + sleep "$POLL_DELAY" +done +[ "$KEYBOARD_SEEN" -eq 1 ] || + fail "missing real keyboard focus trace: $KEY_FRAME_PATTERN" +capture_window "$WINDOW_ID" "$KEYBOARD_CAPTURE" || + fail 'keyboard-focus screenshot failed' +KEYBOARD_HASH=$(capture_hash "$KEYBOARD_CAPTURE") || + fail 'keyboard-focus screenshot hash failed' +[ "$KEYBOARD_HASH" != "$SELECT_HASH" ] || + fail 'keyboard focus and selection did not change presented pixels' +record_metadata keyboard_target_id "$STATE_ID" +record_metadata keyboard_trace "$KEY_SELECTION_PATTERN" +record_metadata keyboard_frame_trace "$KEY_FRAME_PATTERN" +record_metadata keyboard_focus_sha256 "$KEYBOARD_HASH" + +# Tab leaves the segmented control through its one roving stop, wraps to the +# first enabled navigation item, then advances to Typography. The visible ring +# and semantic focus must move together before keyboard activation. +OVERVIEW_ID=20200 +TYPOGRAPHY_ID=20203 +xdotool key --window "$WINDOW_ID" Tab || + fail 'Tab from segmented control to primary navigation failed' +OVERVIEW_FOCUS_PATTERN='has-focus=0 focus-visible=0 hovered-segment=2 token-inspector-open=0 hovered-id=-1 pressed-id=-1 focused-id=20200 general-focus-visible=1 evidence-hash=' +OVERVIEW_FOCUS_SEEN=0 +for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || fail 'preview exited while moving focus to Overview' + if grep -Fq "$OVERVIEW_FOCUS_PATTERN" "$RUNTIME_LOG"; then + OVERVIEW_FOCUS_SEEN=1 + break + fi + sleep "$POLL_DELAY" +done +[ "$OVERVIEW_FOCUS_SEEN" -eq 1 ] || + fail 'Tab did not move actual focus from the segment to Overview' + +xdotool key --window "$WINDOW_ID" Tab || + fail 'Tab from Overview to Typography failed' +TYPOGRAPHY_FOCUS_PATTERN='has-focus=0 focus-visible=0 hovered-segment=2 token-inspector-open=0 hovered-id=-1 pressed-id=-1 focused-id=20203 general-focus-visible=1 evidence-hash=' +TYPOGRAPHY_FOCUS_SEEN=0 +for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || fail 'preview exited while moving focus to Typography' + if grep -Fq "$TYPOGRAPHY_FOCUS_PATTERN" "$RUNTIME_LOG"; then + TYPOGRAPHY_FOCUS_SEEN=1 + break + fi + sleep "$POLL_DELAY" +done +[ "$TYPOGRAPHY_FOCUS_SEEN" -eq 1 ] || + fail 'Tab did not move actual focus to Typography' +capture_window "$WINDOW_ID" "$GENERAL_FOCUS_CAPTURE" || + fail 'navigation-focus screenshot failed' +GENERAL_FOCUS_HASH=$(capture_hash "$GENERAL_FOCUS_CAPTURE") || + fail 'navigation-focus screenshot hash failed' +[ "$GENERAL_FOCUS_HASH" != "$KEYBOARD_HASH" ] || + fail 'navigation focus ring did not change presented pixels' +record_metadata overview_focus_trace "$OVERVIEW_FOCUS_PATTERN" +record_metadata typography_focus_trace "$TYPOGRAPHY_FOCUS_PATTERN" +record_metadata navigation_focus_sha256 "$GENERAL_FOCUS_HASH" + +# Typography is a real retained route, not a static label. Resolve it through +# the same Talkback ID geometry, then activate the real keyboard-focused +# destination. Prove that the hosted specimen replaces the chart, capture all +# three actual font weights, then return to Overview before resize behavior. +read -r TYPOGRAPHY_X TYPOGRAPHY_Y TYPOGRAPHY_WIDTH TYPOGRAPHY_HEIGHT \ + TYPOGRAPHY_ENABLED < <(wait_target_bounds "$TYPOGRAPHY_ID") || + fail "stable target bounds unavailable for ID $TYPOGRAPHY_ID" +[ "$TYPOGRAPHY_ENABLED" -eq 1 ] || + fail "Typography target ID $TYPOGRAPHY_ID is not actionable" +record_metadata typography_target_id "$TYPOGRAPHY_ID" +record_metadata typography_target_bounds \ + "${TYPOGRAPHY_X},${TYPOGRAPHY_Y},${TYPOGRAPHY_WIDTH},${TYPOGRAPHY_HEIGHT}" +record_metadata typography_target_origin talkback-id-plus-keyboard-focus +xdotool key --window "$WINDOW_ID" Return || + fail 'Typography route keyboard activation failed' +TYPOGRAPHY_SELECTION_PATTERN="zagkit: keyboard navigation selection target-id=$TYPOGRAPHY_ID selected-navigation=3" +TYPOGRAPHY_FRAME_PATTERN='zagkit: route selected-navigation=3 typography-composed=1 typography-rows=13' +TYPOGRAPHY_SEEN=0 +for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || fail 'preview exited while opening Typography route' + if grep -Fq "$TYPOGRAPHY_SELECTION_PATTERN" "$RUNTIME_LOG" && + grep -Fq "$TYPOGRAPHY_FRAME_PATTERN" "$RUNTIME_LOG"; then + TYPOGRAPHY_SEEN=1 + break + fi + sleep "$POLL_DELAY" +done +[ "$TYPOGRAPHY_SEEN" -eq 1 ] || + fail 'Typography route did not publish its retained specimen state' +capture_window "$WINDOW_ID" "$TYPOGRAPHY_CAPTURE" || + fail 'Typography route screenshot failed' +TYPOGRAPHY_HASH=$(capture_hash "$TYPOGRAPHY_CAPTURE") || + fail 'Typography route screenshot hash failed' +[ "$TYPOGRAPHY_HASH" != "$GENERAL_FOCUS_HASH" ] || + fail 'Typography route did not change presented pixels' +record_metadata typography_trace "$TYPOGRAPHY_SELECTION_PATTERN" +record_metadata typography_frame_trace "$TYPOGRAPHY_FRAME_PATTERN" +record_metadata typography_sha256 "$TYPOGRAPHY_HASH" + +read -r OVERVIEW_X OVERVIEW_Y OVERVIEW_WIDTH OVERVIEW_HEIGHT OVERVIEW_ENABLED \ + < <(wait_target_bounds "$OVERVIEW_ID") || + fail "stable target bounds unavailable for ID $OVERVIEW_ID" +[ "$OVERVIEW_ENABLED" -eq 1 ] || + fail "Overview target ID $OVERVIEW_ID is not actionable" +OVERVIEW_CLICK_X=$((OVERVIEW_X + OVERVIEW_WIDTH / 2)) +OVERVIEW_CLICK_Y=$((OVERVIEW_Y + OVERVIEW_HEIGHT / 2)) +xdotool mousemove --window "$WINDOW_ID" \ + "$OVERVIEW_CLICK_X" "$OVERVIEW_CLICK_Y" click 1 || + fail 'Overview route return injection failed' +OVERVIEW_SELECTION_PATTERN="zagkit: navigation selection target-id=$OVERVIEW_ID selected-navigation=0" +OVERVIEW_FRAME_PATTERN='zagkit: route selected-navigation=0 typography-composed=0 typography-rows=0' +OVERVIEW_SEEN=0 +for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || fail 'preview exited while returning to Overview' + if grep -Fq "$OVERVIEW_SELECTION_PATTERN" "$RUNTIME_LOG" && + grep -Fq "$OVERVIEW_FRAME_PATTERN" "$RUNTIME_LOG"; then + OVERVIEW_SEEN=1 + break + fi + sleep "$POLL_DELAY" +done +[ "$OVERVIEW_SEEN" -eq 1 ] || + fail 'Overview route did not restore the canonical chart composition' +record_metadata overview_return_trace "$OVERVIEW_SELECTION_PATTERN" + +# Resize below the 480x360 desktop composition minimum. The shell must remain +# alive and present the explicit semantic resize state, never a cropped chart. +xdotool windowsize "$WINDOW_ID" 320 240 || + fail '320x240 unsupported-size request failed' +SMALL_MATCHED=0 +UNSUPPORTED_PATTERN='zagkit: unsupported-size target-id=20750 minimum=480x360' +for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || fail 'preview exited during unsupported-size resize' + if read -r SMALL_WIDTH SMALL_HEIGHT < <(window_geometry "$WINDOW_ID") && + [ "$SMALL_WIDTH" -eq 320 ] && [ "$SMALL_HEIGHT" -eq 240 ] && + grep -Fq "$UNSUPPORTED_PATTERN" "$RUNTIME_LOG"; then + SMALL_MATCHED=1 + break + fi + sleep "$POLL_DELAY" +done +[ "$SMALL_MATCHED" -eq 1 ] || + fail 'native shell did not publish its explicit unsupported-size state' +capture_window "$WINDOW_ID" "$SMALL_CAPTURE" || + fail 'unsupported-size screenshot failed' +SMALL_FILE=$(file -b "$SMALL_CAPTURE") +case "$SMALL_FILE" in + *'320 x 240'*) ;; + *) fail "unsupported-size screenshot dimensions disagree: $SMALL_FILE" ;; +esac +SMALL_HASH=$(capture_hash "$SMALL_CAPTURE") || + fail 'unsupported-size screenshot hash failed' +record_metadata unsupported_target_id 20750 +record_metadata unsupported_geometry 320x240 +record_metadata unsupported_sha256 "$SMALL_HASH" + +xdotool windowsize "$WINDOW_ID" 800 600 || fail '800x600 resize request failed' +RESIZE_MATCHED=0 +for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || fail 'preview exited while waiting for resize' + if read -r RESIZED_WIDTH RESIZED_HEIGHT < <(window_geometry "$WINDOW_ID") && + [ "$RESIZED_WIDTH" -eq 800 ] && [ "$RESIZED_HEIGHT" -eq 600 ]; then + RESIZE_MATCHED=1 + break + fi + sleep "$POLL_DELAY" +done +[ "$RESIZE_MATCHED" -eq 1 ] || fail 'window geometry did not reach 800x600' +record_metadata resized_geometry 800x600 + +RESIZED_DIMENSIONS_MATCHED=0 +for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || fail 'preview exited while waiting for resized screenshot' + if capture_window "$WINDOW_ID" "$RESIZED_CAPTURE"; then + RESIZED_FILE=$(file -b "$RESIZED_CAPTURE") + case "$RESIZED_FILE" in + *'800 x 600'*) RESIZED_DIMENSIONS_MATCHED=1; break ;; + esac + fi + sleep "$POLL_DELAY" +done +[ "$RESIZED_DIMENSIONS_MATCHED" -eq 1 ] || + fail 'resized native screenshot is not 800x600' +RESIZED_HASH=$(capture_hash "$RESIZED_CAPTURE") || fail 'resized screenshot hash failed' +record_metadata resized_sha256 "$RESIZED_HASH" + +# Wait for four consecutive 50 ms samples without a CPU-tick change before +# measuring the formal one-second idle window. The measured window remains +# fail-closed and cannot be skipped by a slow final resize render. +SETTLE_STREAK=0 +SETTLE_TICKS=$(proc_cpu_ticks "$PREVIEW_PID") || fail 'cannot read preview CPU ticks' +for ((attempt = 0; attempt < 40; attempt++)); do + preview_process_running || fail 'preview exited before idle measurement' + sleep "$POLL_DELAY" + CURRENT_TICKS=$(proc_cpu_ticks "$PREVIEW_PID") || fail 'cannot sample preview CPU ticks' + if [ "$CURRENT_TICKS" -eq "$SETTLE_TICKS" ]; then + SETTLE_STREAK=$((SETTLE_STREAK + 1)) + if [ "$SETTLE_STREAK" -ge 4 ]; then break; fi + else + SETTLE_STREAK=0 + SETTLE_TICKS=$CURRENT_TICKS + fi +done +[ "$SETTLE_STREAK" -ge 4 ] || fail 'preview did not become idle after resize' + +IDLE_TICKS_BEFORE=$(proc_cpu_ticks "$PREVIEW_PID") || fail 'cannot begin idle CPU measurement' +for ((attempt = 0; attempt < 20; attempt++)); do + preview_process_running || fail 'preview exited during idle CPU measurement' + sleep "$POLL_DELAY" +done +IDLE_TICKS_AFTER=$(proc_cpu_ticks "$PREVIEW_PID") || fail 'cannot finish idle CPU measurement' +IDLE_TICK_DELTA=$((IDLE_TICKS_AFTER - IDLE_TICKS_BEFORE)) +[ "$IDLE_TICK_DELTA" -ge 0 ] || fail 'preview CPU tick counter regressed' +[ "$IDLE_TICK_DELTA" -le "$IDLE_MAX_CPU_TICKS" ] || + fail "idle CPU used $IDLE_TICK_DELTA ticks; maximum is $IDLE_MAX_CPU_TICKS" +record_metadata idle_cpu_ticks "$IDLE_TICK_DELTA" +record_metadata idle_samples 20x0.05s + +xdotool windowclose "$WINDOW_ID" || fail 'native close request failed' +for ((attempt = 0; attempt < POLL_ATTEMPTS; attempt++)); do + preview_process_running || break + sleep "$POLL_DELAY" +done +preview_process_running && fail 'preview did not exit after native close request' + +set +e +wait "$PREVIEW_PID" +PREVIEW_EXIT=$? +set -e +PREVIEW_WAITED=1 +[ "$PREVIEW_EXIT" -eq 0 ] || fail "preview exited with status $PREVIEW_EXIT" +record_metadata preview_exit "$PREVIEW_EXIT" + +printf 'linux interaction test: PASS (pid=%s window=%s disabled_id=%s selected_id=%s idle_ticks=%s)\n' \ + "$PREVIEW_PID" "$WINDOW_ID" "$COMPONENTS_ID" "$MOTION_ID" \ + "$IDLE_TICK_DELTA" +if [ -n "$EVIDENCE_DIR" ]; then + printf 'linux interaction test: evidence -> %s\n' "$EVIDENCE_DIR" +fi diff --git a/tools/test-linux-preview-composition.sh b/tools/test-linux-preview-composition.sh new file mode 100755 index 0000000..f35f45d --- /dev/null +++ b/tools/test-linux-preview-composition.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +znc=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} +light=${1:-} +regular=${2:-} +bold=${3:-} + +if [ ! -x "$znc" ]; then + printf 'Linux preview composition: FAIL: Zag compiler not executable: %s\n' "$znc" >&2 + exit 2 +fi +if [ "$#" -ne 0 ] && [ "$#" -ne 3 ]; then + printf '%s\n' \ + 'usage: tools/test-linux-preview-composition.sh [light regular bold]' >&2 + exit 2 +fi +if [ -z "$regular" ] && command -v fc-match >/dev/null 2>&1; then + light=$(fc-match -f '%{file}\n' 'Fira Sans:style=Light' | head -n 1) + regular=$(fc-match -f '%{file}\n' 'Fira Sans:style=Regular' | head -n 1) + bold=$(fc-match -f '%{file}\n' 'Fira Sans:style=Bold' | head -n 1) +fi +if [ -z "$light" ] || [ -z "$regular" ] || [ -z "$bold" ] || + [ ! -f "$light" ] || [ ! -f "$regular" ] || [ ! -f "$bold" ]; then + printf 'Linux preview composition: SKIP: three font weights were not resolved\n' + exit 77 +fi +if cmp -s -- "$light" "$regular" || cmp -s -- "$regular" "$bold" || + cmp -s -- "$light" "$bold"; then + printf 'Linux preview composition: SKIP: resolved font weights are not distinct\n' + exit 77 +fi + +binary=$(mktemp /tmp/zagkit-test-linux-preview-composition.XXXXXX) +cleanup() { rm -f -- "$binary"; } +trap cleanup EXIT + +"$znc" "$root/tests/linux_preview_composition_contract.zag" --no-zagd \ + --analyze-strict --no-foreground-cache -o "$binary" +"$binary" "$light" "$regular" "$bold" diff --git a/tools/test-scroll-viewport.sh b/tools/test-scroll-viewport.sh new file mode 100755 index 0000000..e07da53 --- /dev/null +++ b/tools/test-scroll-viewport.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +znc=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} + +if [ ! -x "$znc" ]; then + printf 'scroll viewport contract: FAIL: Zag compiler not executable: %s\n' "$znc" >&2 + exit 2 +fi + +binary=$(mktemp /tmp/zagkit-test-scroll-viewport.XXXXXX) +cleanup() { rm -f -- "$binary"; } +trap cleanup EXIT + +"$znc" "$root/tests/scroll_viewport_contract.zag" --no-zagd \ + --analyze-strict --no-foreground-cache -o "$binary" +"$binary" diff --git a/tools/test-segmented-control.sh b/tools/test-segmented-control.sh new file mode 100755 index 0000000..be03377 --- /dev/null +++ b/tools/test-segmented-control.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +znc=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} + +if [ ! -x "$znc" ]; then + printf 'segmented control contract: FAIL: Zag compiler not executable: %s\n' "$znc" >&2 + exit 2 +fi + +binary=$(mktemp /tmp/zagkit-test-segmented-control.XXXXXX) +cleanup() { rm -f -- "$binary"; } +trap cleanup EXIT + +"$znc" "$root/tests/segmented_control_contract.zag" --no-zagd \ + --analyze-strict --no-foreground-cache -o "$binary" +"$binary" diff --git a/tools/test-surface.sh b/tools/test-surface.sh new file mode 100755 index 0000000..e6b0e84 --- /dev/null +++ b/tools/test-surface.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +znc=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} + +if [ ! -x "$znc" ]; then + printf 'surface contract: FAIL: Zag compiler not executable: %s\n' "$znc" >&2 + exit 2 +fi + +binary=$(mktemp /tmp/zagkit-test-surface.XXXXXX) +negative="$root/tests/negative/surface_return_lifetime.zag" +negative_binary=$(mktemp /tmp/zagkit-test-surface-negative.XXXXXX) +negative_log=$(mktemp /tmp/zagkit-test-surface-negative.XXXXXX.log) +cleanup() { + rm -f -- "$binary" "$negative_binary" "$negative_log" +} +trap cleanup EXIT + +"$znc" "$root/tests/surface_contract.zag" --no-zagd \ + --analyze-strict --no-foreground-cache -o "$binary" +"$binary" + +if "$znc" "$negative" --no-zagd --analyze-strict --no-foreground-cache \ + -o "$negative_binary" >"$negative_log" 2>&1; then + printf '%s\n' \ + 'surface lifetime negative: FAIL: released backing compiled successfully' >&2 + exit 1 +fi + +if ! grep -q 'E0204' "$negative_log" || + ! grep -Eqi 'borrow|retain|release|lifetime' "$negative_log"; then + printf '%s\n' \ + 'surface lifetime negative: FAIL: expected structured lifetime rejection' >&2 + sed -n '1,120p' "$negative_log" >&2 + exit 1 +fi + +printf '%s\n' \ + 'surface lifetime negative: ok: backing release rejected before retained spec use' diff --git a/tools/test-typography-specimen.sh b/tools/test-typography-specimen.sh new file mode 100755 index 0000000..6d595ab --- /dev/null +++ b/tools/test-typography-specimen.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +znc=${ZNC:-/home/micah/Desktop/Sylorlabs/zag/zag-poc/znc} + +if [ "$#" -ne 0 ] && [ "$#" -ne 3 ]; then + printf '%s\n' \ + 'usage: test-typography-specimen.sh [light.ttf regular.ttf bold.ttf]' >&2 + exit 2 +fi + +light=${ZAGKIT_TYPOGRAPHY_LIGHT_FONT:-${1:-/usr/share/fonts/truetype/dejavu/DejaVuSans-ExtraLight.ttf}} +regular=${ZAGKIT_TYPOGRAPHY_REGULAR_FONT:-${2:-/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf}} +bold=${ZAGKIT_TYPOGRAPHY_BOLD_FONT:-${3:-/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf}} + +if [ ! -x "$znc" ]; then + printf 'typography specimen contract: FAIL: Zag compiler not executable: %s\n' \ + "$znc" >&2 + exit 2 +fi + +for font in "$light" "$regular" "$bold"; do + if [ ! -f "$font" ]; then + printf 'typography specimen contract: FAIL: required font missing: %s\n' \ + "$font" >&2 + exit 2 + fi +done + +if cmp -s -- "$light" "$regular" || cmp -s -- "$light" "$bold" || + cmp -s -- "$regular" "$bold"; then + printf '%s\n' \ + 'typography specimen contract: FAIL: light regular and bold inputs must be distinct files' >&2 + exit 2 +fi + +binary=$(mktemp /tmp/zagkit-test-typography-specimen.XXXXXX) +cleanup() { rm -f -- "$binary"; } +trap cleanup EXIT + +"$znc" "$root/tests/typography_specimen_contract.zag" --no-zagd \ + --analyze-strict --no-foreground-cache -o "$binary" +"$binary" "$light" "$regular" "$bold" diff --git a/zag.mod b/zag.mod index fc46da1..600dbaf 100644 --- a/zag.mod +++ b/zag.mod @@ -1,3 +1,3 @@ name = "zagkit" version = "0.1.0-experimental.0" -edition = "2026" +edition = "2027"