From 7279869d68c09829de08b36cd74633e9dd63c0db Mon Sep 17 00:00:00 2001 From: Sai Nimmagadda Date: Sat, 12 Sep 2026 15:16:33 -0400 Subject: [PATCH 01/11] feat: product direction --- docs/README.md | 2 + docs/delivery/README.md | 65 + docs/delivery/backlog.md | 60 + docs/delivery/contracts/adapters.md | 32 + docs/delivery/contracts/playwright.md | 42 + docs/delivery/contracts/runtime.md | 79 ++ docs/delivery/contracts/shipping.md | 31 + docs/delivery/evidence/README.md | 29 + docs/delivery/tasks/AD-ANGULAR.md | 21 + docs/delivery/tasks/AD-INTEGRATE.md | 21 + docs/delivery/tasks/AD-REACT.md | 21 + docs/delivery/tasks/AD-SVELTE.md | 21 + docs/delivery/tasks/AD-VUE.md | 21 + docs/delivery/tasks/AGENT-01.md | 23 + docs/delivery/tasks/AGENT-02.md | 22 + docs/delivery/tasks/AGENT-03.md | 21 + docs/delivery/tasks/BASE-01.md | 24 + docs/delivery/tasks/BOX-01.md | 21 + docs/delivery/tasks/BOX-02.md | 22 + docs/delivery/tasks/CONTRACT-01.md | 24 + docs/delivery/tasks/CONTRACT-02.md | 24 + docs/delivery/tasks/CONTRACT-03.md | 24 + docs/delivery/tasks/CONTRACT-04.md | 24 + docs/delivery/tasks/CONTRACT-05.md | 24 + docs/delivery/tasks/CORE-01.md | 24 + docs/delivery/tasks/CORE-02.md | 26 + docs/delivery/tasks/CORE-03.md | 28 + docs/delivery/tasks/CORE-04.md | 24 + docs/delivery/tasks/CORE-05.md | 25 + docs/delivery/tasks/CORE-06.md | 24 + docs/delivery/tasks/CORE-07.md | 24 + docs/delivery/tasks/CORE-08.md | 25 + docs/delivery/tasks/DOCS-01.md | 23 + docs/delivery/tasks/EVIDENCE-01.md | 21 + docs/delivery/tasks/EVIDENCE-02.md | 21 + docs/delivery/tasks/EVIDENCE-03.md | 21 + docs/delivery/tasks/EVIDENCE-04.md | 21 + docs/delivery/tasks/EVIDENCE-05.md | 21 + docs/delivery/tasks/EVIDENCE-06.md | 21 + docs/delivery/tasks/EVIDENCE-07.md | 21 + docs/delivery/tasks/EVIDENCE-08.md | 21 + docs/delivery/tasks/FIX-01.md | 25 + docs/delivery/tasks/FIX-02.md | 24 + docs/delivery/tasks/FOLLOW-01.md | 21 + docs/delivery/tasks/FOLLOW-02.md | 21 + docs/delivery/tasks/FOLLOW-03.md | 21 + docs/delivery/tasks/FOLLOW-04.md | 21 + docs/delivery/tasks/FOLLOW-05.md | 21 + docs/delivery/tasks/FOLLOW-06.md | 21 + docs/delivery/tasks/FOLLOW-07.md | 21 + docs/delivery/tasks/FOLLOW-08.md | 21 + docs/delivery/tasks/FOLLOW-09.md | 21 + docs/delivery/tasks/FOLLOW-10.md | 21 + docs/delivery/tasks/FOLLOW-11.md | 21 + docs/delivery/tasks/FOLLOW-12.md | 21 + docs/delivery/tasks/FOLLOW-13.md | 21 + docs/delivery/tasks/FOLLOW-14.md | 21 + docs/delivery/tasks/HANDOFF-01.md | 21 + docs/delivery/tasks/MEASURE-01.md | 24 + docs/delivery/tasks/PW-01.md | 23 + docs/delivery/tasks/PW-02.md | 21 + docs/delivery/tasks/PW-03.md | 21 + docs/delivery/tasks/PW-04.md | 21 + docs/delivery/tasks/PW-05.md | 21 + docs/delivery/tasks/PW-06.md | 22 + docs/delivery/tasks/PW-07.md | 21 + docs/delivery/tasks/PW-08.md | 21 + docs/delivery/tasks/RELEASE-01.md | 26 + docs/delivery/tasks/RELEASE-02.md | 21 + docs/delivery/tasks/SHIP-01.md | 22 + docs/delivery/tasks/SHIP-02.md | 21 + docs/delivery/tasks/SHIP-03.md | 21 + docs/delivery/tasks/WEB-01.md | 23 + docs/delivery/tasks/WEB-02.md | 22 + docs/product-direction.md | 1727 +++++++++++++++++++++++++ 75 files changed, 3531 insertions(+) create mode 100644 docs/delivery/README.md create mode 100644 docs/delivery/backlog.md create mode 100644 docs/delivery/contracts/adapters.md create mode 100644 docs/delivery/contracts/playwright.md create mode 100644 docs/delivery/contracts/runtime.md create mode 100644 docs/delivery/contracts/shipping.md create mode 100644 docs/delivery/evidence/README.md create mode 100644 docs/delivery/tasks/AD-ANGULAR.md create mode 100644 docs/delivery/tasks/AD-INTEGRATE.md create mode 100644 docs/delivery/tasks/AD-REACT.md create mode 100644 docs/delivery/tasks/AD-SVELTE.md create mode 100644 docs/delivery/tasks/AD-VUE.md create mode 100644 docs/delivery/tasks/AGENT-01.md create mode 100644 docs/delivery/tasks/AGENT-02.md create mode 100644 docs/delivery/tasks/AGENT-03.md create mode 100644 docs/delivery/tasks/BASE-01.md create mode 100644 docs/delivery/tasks/BOX-01.md create mode 100644 docs/delivery/tasks/BOX-02.md create mode 100644 docs/delivery/tasks/CONTRACT-01.md create mode 100644 docs/delivery/tasks/CONTRACT-02.md create mode 100644 docs/delivery/tasks/CONTRACT-03.md create mode 100644 docs/delivery/tasks/CONTRACT-04.md create mode 100644 docs/delivery/tasks/CONTRACT-05.md create mode 100644 docs/delivery/tasks/CORE-01.md create mode 100644 docs/delivery/tasks/CORE-02.md create mode 100644 docs/delivery/tasks/CORE-03.md create mode 100644 docs/delivery/tasks/CORE-04.md create mode 100644 docs/delivery/tasks/CORE-05.md create mode 100644 docs/delivery/tasks/CORE-06.md create mode 100644 docs/delivery/tasks/CORE-07.md create mode 100644 docs/delivery/tasks/CORE-08.md create mode 100644 docs/delivery/tasks/DOCS-01.md create mode 100644 docs/delivery/tasks/EVIDENCE-01.md create mode 100644 docs/delivery/tasks/EVIDENCE-02.md create mode 100644 docs/delivery/tasks/EVIDENCE-03.md create mode 100644 docs/delivery/tasks/EVIDENCE-04.md create mode 100644 docs/delivery/tasks/EVIDENCE-05.md create mode 100644 docs/delivery/tasks/EVIDENCE-06.md create mode 100644 docs/delivery/tasks/EVIDENCE-07.md create mode 100644 docs/delivery/tasks/EVIDENCE-08.md create mode 100644 docs/delivery/tasks/FIX-01.md create mode 100644 docs/delivery/tasks/FIX-02.md create mode 100644 docs/delivery/tasks/FOLLOW-01.md create mode 100644 docs/delivery/tasks/FOLLOW-02.md create mode 100644 docs/delivery/tasks/FOLLOW-03.md create mode 100644 docs/delivery/tasks/FOLLOW-04.md create mode 100644 docs/delivery/tasks/FOLLOW-05.md create mode 100644 docs/delivery/tasks/FOLLOW-06.md create mode 100644 docs/delivery/tasks/FOLLOW-07.md create mode 100644 docs/delivery/tasks/FOLLOW-08.md create mode 100644 docs/delivery/tasks/FOLLOW-09.md create mode 100644 docs/delivery/tasks/FOLLOW-10.md create mode 100644 docs/delivery/tasks/FOLLOW-11.md create mode 100644 docs/delivery/tasks/FOLLOW-12.md create mode 100644 docs/delivery/tasks/FOLLOW-13.md create mode 100644 docs/delivery/tasks/FOLLOW-14.md create mode 100644 docs/delivery/tasks/HANDOFF-01.md create mode 100644 docs/delivery/tasks/MEASURE-01.md create mode 100644 docs/delivery/tasks/PW-01.md create mode 100644 docs/delivery/tasks/PW-02.md create mode 100644 docs/delivery/tasks/PW-03.md create mode 100644 docs/delivery/tasks/PW-04.md create mode 100644 docs/delivery/tasks/PW-05.md create mode 100644 docs/delivery/tasks/PW-06.md create mode 100644 docs/delivery/tasks/PW-07.md create mode 100644 docs/delivery/tasks/PW-08.md create mode 100644 docs/delivery/tasks/RELEASE-01.md create mode 100644 docs/delivery/tasks/RELEASE-02.md create mode 100644 docs/delivery/tasks/SHIP-01.md create mode 100644 docs/delivery/tasks/SHIP-02.md create mode 100644 docs/delivery/tasks/SHIP-03.md create mode 100644 docs/delivery/tasks/WEB-01.md create mode 100644 docs/delivery/tasks/WEB-02.md create mode 100644 docs/product-direction.md diff --git a/docs/README.md b/docs/README.md index 757b6a1..ea2818b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,8 @@ These are contributor documents; you do not need them to start using the library | Document | Purpose | | --- | --- | +| [Product direction](product-direction.md) | Agreed positioning, product boundaries, release scope, adoption tests and dependency-ordered backlog. | +| [Delivery plan](delivery/README.md) | Approved contracts and executable implementation task packets. | | [Agent architecture](agent-architecture.md) | Design of capabilities, plans, templates and skills. | | [Agent evaluations](agent-evals.md) | Evaluation methods and recorded outcomes, including failures. | | [Agent verification](agent-verification.md) | Dated release verification results; not a live CI status page. | diff --git a/docs/delivery/README.md b/docs/delivery/README.md new file mode 100644 index 0000000..0a19320 --- /dev/null +++ b/docs/delivery/README.md @@ -0,0 +1,65 @@ +# Stet delivery plan + +This directory turns [the product direction](../product-direction.md) into work +that can be assigned to bounded implementation agents. + +## Authority + +1. `contracts/` contains approved behavior. Workers must not reinterpret it. +2. `backlog.md` records dependencies, release scope, and status. +3. `tasks/` contains one executable assignment per task. +4. `evidence/` defines how checks and artifacts are recorded. + +The product-direction document explains why; this directory defines how. If they +conflict, stop and resolve the contract before changing code. + +## Workflow + +Tasks move through: + +```text +BLOCKED -> READY -> IN_PROGRESS -> REVIEW -> ACCEPTED +``` + +Only the integrator marks a task `ACCEPTED`. A worker may return `REVIEW` or +`BLOCKED`. A task is `READY` only when its prerequisites are accepted, its scope +matches the current revision, and no relevant contract question remains. + +## Worker rules + +- Read the task, contracts, listed code, and callers before editing. +- Edit only the allowed scope. Stop if correctness requires another file. +- Do not add dependencies, public APIs, or abstractions unless specified. +- Do not hand-edit generated files or weaken tests to obtain a pass. +- Do not update snapshots or size limits without explicit acceptance criteria. +- Include the smallest regression check with nontrivial behavior. +- After two failed fixes for the same failure, stop with evidence. +- Never commit, push, publish, or contact users unless explicitly authorized. + +## Verification commands + +Tasks select the narrowest relevant subset from `package.json`: + +```bash +npm test +npm run check +npm run test:agent +npm run test:templates +npm run test:package +npm run test:browser +npm run test:patterns +npm run test:cli-consumer +npm run size +npm --prefix website run check +npm --prefix website test +``` + +`npm run build` generates agent artifacts. Generated changes must be declared and +reviewed. Missing browsers or tools mean unverified, not passed. + +## Dispatch + +Shared runtime files (`src/mount.ts`, `src/primitives.ts`, `src/rough.ts`, +`style.css`), package metadata, and generator/catalog files have one writer at a time. Framework adapter +tasks may run in parallel only after the shared runtime is accepted. The first +dispatch batch is `BASE-01`; contract tasks follow its evidence. diff --git a/docs/delivery/backlog.md b/docs/delivery/backlog.md new file mode 100644 index 0000000..9fdedef --- /dev/null +++ b/docs/delivery/backlog.md @@ -0,0 +1,60 @@ +# Executable backlog + +See [execution rules](README.md) and the approved [contracts](contracts/). + +| ID | Class | Parent | Direct dependencies | State | +| --- | --- | --- | --- | --- | +| BASE-01 | Required | A1/A5/H2 | — | READY | +| CONTRACT-01 | Required | A3 | BASE-01 | BLOCKED | +| CONTRACT-02 | Required | A4/B6 | CONTRACT-01 | BLOCKED | +| CONTRACT-03 | Required | D1 | CONTRACT-01 | BLOCKED | +| CONTRACT-04 | Required | E1 | BASE-01, CONTRACT-02 | BLOCKED | +| CONTRACT-05 | Required | Release planning | CONTRACT-01..04 | BLOCKED | +| FIX-01 | Required | A1 | BASE-01 | BLOCKED | +| FIX-02 | Required | A6 | FIX-01 | BLOCKED | +| MEASURE-01 | Required | A5 | BASE-01 | BLOCKED | +| CORE-01 | Required | B1 | CONTRACT-01 | BLOCKED | +| CORE-02 | Required | B2/F1 | CORE-01 | BLOCKED | +| CORE-03 | Required | B2 | CORE-02 | BLOCKED | +| CORE-04 | Required | B2 | CORE-03 | BLOCKED | +| CORE-05 | Required | B4 | CORE-04 | BLOCKED | +| CORE-06 | Required | B5 | CORE-05 | BLOCKED | +| CORE-07 | Required | Required internal B6 subset | CONTRACT-02, CORE-06 | BLOCKED | +| CORE-08 | Required | A5 | MEASURE-01, CORE-06, BOX-02 | BLOCKED | +| AD-REACT | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | BLOCKED | +| AD-VUE | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | BLOCKED | +| AD-SVELTE | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | BLOCKED | +| AD-ANGULAR | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | BLOCKED | +| AD-INTEGRATE | Required | B7/F2 | All AD tasks | BLOCKED | +| BOX-01 | Required | C1 | CORE-06 | BLOCKED | +| BOX-02 | Required | C1/F1 | BOX-01, AD-INTEGRATE | BLOCKED | +| PW-01 | Required gate | D1/D2 | CONTRACT-03 | BLOCKED | +| PW-02 | Required gate | D1/D2/D3 | PW-01, CORE-05 | BLOCKED | +| PW-03 | Required | D2 | PW-02 | BLOCKED | +| PW-04 | Required | D2/D3 | PW-03 | BLOCKED | +| PW-05 | Required | D3 | PW-04, CORE-05 | BLOCKED | +| PW-06 | Required | D1/D3 | PW-05 | BLOCKED | +| PW-07 | Required | D4 | PW-05, CORE-06 | BLOCKED | +| PW-08 | Required | D6 | PW-05 | BLOCKED | +| SHIP-01 | Required | E2 | CONTRACT-04 | BLOCKED | +| SHIP-02 | Required | E4 | SHIP-01 | BLOCKED | +| SHIP-03 | Required | E1/F4 | SHIP-02 | BLOCKED | +| AGENT-01 | Required/final audit | F1 | AGENT-02, PW-03 | BLOCKED | +| AGENT-02 | Required | F2 | AD-INTEGRATE, CORE-05, BOX-02 | BLOCKED | +| AGENT-03 | Required | F3 | PW-06, SHIP-03 | BLOCKED | +| HANDOFF-01 | Required | D7 | PW-07, PW-08, AGENT-03 | BLOCKED | +| WEB-01 | Required | G1 | FIX-01, CORE-06 | BLOCKED | +| WEB-02 | Required | G2 | FIX-02, AGENT-02, HANDOFF-01 | BLOCKED | +| DOCS-01 | Required | A2/G4 | BOX-02, PW-06, SHIP-03 | BLOCKED | +| RELEASE-01 | Required gate | H2 | CONTRACT-05, FIX-01..02, MEASURE-01, CORE-01..08, all AD, BOX-01..02, PW-01..08, SHIP-01..03, AGENT-01..03, HANDOFF-01, WEB-01..02, DOCS-01 | BLOCKED | +| RELEASE-02 | Required gate | A1/G1/G2 | RELEASE-01 | BLOCKED | +| FOLLOW-01..14 | Conditional/post-release | B3/B8/C2-C6/D5/E3/G3 | See task files | BLOCKED | +| EVIDENCE-01..08 | Human-led | G5/G6/H1/F5/F6 | See task files | BLOCKED | + +## First dispatch + +Run `BASE-01`, then prepare `CONTRACT-01` through `CONTRACT-04`, `FIX-01`, and +`MEASURE-01`. Runtime implementation starts only after its contracts are accepted. +Playwright implementation starts only after `PW-01` and `PW-02` produce accepted +evidence. Framework writers start only after the shared runtime and adapter +contract are stable. diff --git a/docs/delivery/contracts/adapters.md b/docs/delivery/contracts/adapters.md new file mode 100644 index 0000000..f732e22 --- /dev/null +++ b/docs/delivery/contracts/adapters.md @@ -0,0 +1,32 @@ +# Framework adapter contract + +Status: approved behavior; exact framework syntax is finalized by `CONTRACT-02` + +Every existing adapter supports declarative visibility and access to the complete +runtime handle through adapter-only metadata: + +```ts +onHandle?: (handle: StetHandle | null) => void; +``` + +`onHandle` is not a runtime option and never appears in plans or generated option +schemas. + +## Shared rules + +- Notify with a handle after attachment and `null` when it becomes unavailable. +- Changing callback identity does not remount or replay. +- A changed declarative `visible` value calls `show()` or `hide()`. +- An unchanged value does not overwrite an imperative show/hide operation. +- Nonvisibility updates use the internal update path and do not replay. +- Animation-option changes apply to the next entrance. +- Target replacement cancels the old handle and attaches a new one. +- Unmount/destruction reports `null` and performs idempotent cleanup. +- Existing SSR, hydration, StrictMode, directive, and action behavior remains. + +Framework syntax must remain idiomatic for React 18+, Vue directives, Svelte +actions, and Angular standalone directives. No adapter-specific animation engine +or controller registry may be introduced. + +Template sources are `agent/snippets.mjs` and `agent/patterns.mjs`; generated files +under `agent/templates/` are never edited by hand. diff --git a/docs/delivery/contracts/playwright.md b/docs/delivery/contracts/playwright.md new file mode 100644 index 0000000..6c457a6 --- /dev/null +++ b/docs/delivery/contracts/playwright.md @@ -0,0 +1,42 @@ +# Playwright integration contract + +Status: approved product boundary; loading details are evidence-gated by `PW-01` +and `PW-02` + +## Package boundary + +- Ship a supported optional `@funsaized/stet/playwright` entry. +- Build its browser payload while building Stet. +- Load package-local assets; never fetch a CDN. +- Consumers do not need esbuild. +- Callers supply their own Playwright installation and objects. +- Browser-core graphs contain no Node or Playwright code. + +## Behavior + +- Create an owned annotation session for a `Page` or same-origin `Frame`. +- Accept Playwright locators and require exactly one live target at attachment. +- Validate that targets belong to the session document. +- Expose asynchronous remote handles matching the runtime result semantics. +- Multiple sessions and source-authored Stet annotations coexist. +- Cleanup removes only resources owned by that session. +- Navigation invalidates the document and cancels pending work. +- Do not automatically reinject, rediscover targets, or cross documents. +- Cross-document arrows and cross-origin frames are unsupported initially. +- Do not silently enable CSP bypass. + +Missing, ambiguous, detached, wrong-document, navigation, loading, and CSP failures +must be distinguishable and actionable. Normal runtime animation cancellation is +not an exceptional browser error. + +## Evidence gates + +`PW-01` must prove renderer and stylesheet loading, restrictive-CSP behavior, +Chromium and Firefox, same-origin frames, rollback, and source-authored +coexistence. WebKit evidence may be collected with `STET_WEBKIT=1`, but is not a +first-release support gate unless that run is made mandatory before release. + +`PW-02` must prove packed payload discovery, no consumer bundler dependency, +navigation cancellation, cleanup ownership, and browser-core isolation. + +No worker may invent the final loader before both gates are accepted. diff --git a/docs/delivery/contracts/runtime.md b/docs/delivery/contracts/runtime.md new file mode 100644 index 0000000..900918c --- /dev/null +++ b/docs/delivery/contracts/runtime.md @@ -0,0 +1,79 @@ +# Runtime contract + +Status: approved for implementation planning + +## Options + +```ts +visible?: boolean; +animate?: boolean; +animationDuration?: number; +animationDelay?: number; +``` + +- `visible` defaults to `true`. +- Animation defaults off. Supplying duration or delay opts in unless + `animate: false` is explicit. +- Enabled duration defaults to 600 ms; delay defaults to 0 ms. +- Duration and delay must be finite and nonnegative. +- Zero duration ignores delay and settles immediately. +- Reduced motion settles immediately. +- Initial `visible: false` defers entrance until `show()`. +- First-release reveal supports `circle`, `underline`, and `box`. +- Unsupported animation fails before DOM or ARIA mutation. +- Easing, exit animation, reveal direction, and timelines are out of scope. + +The options remain flat because the current schema generator supports scalars and +literal unions, not nested objects. + +## Handle + +```ts +type StetAnimationResult = { status: "finished" | "cancelled" }; + +interface StetHandle { + show(): Promise; + hide(): void; + replay(): Promise; + refresh(): void; + resketch(seed?: number): void; + destroy(): void; +} +``` + +## State rules + +| Event | Result | +| --- | --- | +| Static attach | Visible and settled | +| Animated attach | One entrance | +| Hidden attach | No entrance until shown | +| Show during entrance | Same in-flight promise; no restart | +| Show while settled-visible | Immediate `finished` | +| Show after hide | New entrance if enabled | +| Replay | Visible, restart; prior operation becomes `cancelled` | +| Hide during entrance | Immediate hide; pending result `cancelled` | +| Destroy during entrance | Cleanup; pending result `cancelled` | +| Reduced motion during entrance | Final state; result `finished` | +| Calls after destroy | show/replay return `cancelled`; others are harmless | + +`refresh`, `resketch`, layout observation, scrolling, fonts, and theme changes do +not replay. Viewport culling neither pauses nor starts animation. Offscreen +animation may finish offscreen; viewport-triggered reveal is a separate recipe. + +## Accessibility ownership + +- Explicit hide detaches every Stet-owned `aria-describedby` relationship. +- Show restores those relationships. +- Viewport culling preserves them. +- Destroy removes them. +- Existing native and other annotation IDs are preserved. +- This includes mount descriptions, arrow labels, and sticky text. + +## Updates + +The first release needs an internal nonreplaying update path, not a public generic +`update()` method. Styling, text, and placement updates preserve current visibility +and do not start a fresh entrance. Animation settings affect the next entrance. +Target replacement cancels the old handle and applies normal initial attachment to +the new target. diff --git a/docs/delivery/contracts/shipping.md b/docs/delivery/contracts/shipping.md new file mode 100644 index 0000000..dc84a08 --- /dev/null +++ b/docs/delivery/contracts/shipping.md @@ -0,0 +1,31 @@ +# Shipping contract + +Status: approved for first-release implementation + +## Modes + +| Mode | Result | +| --- | --- | +| Production | Intentional annotations ship | +| Preview-only | Review annotations ship only to previews | +| Local-only | Review annotations are available only in development | +| Mixed | Intentional production annotations remain; review annotations do not | + +## Guarantees + +1. **Disabled:** no annotation renders; code may remain. +2. **Runtime excluded:** unused Stet JavaScript and CSS are absent. +3. **Content excluded:** temporary annotation copy, plans, and setup are absent. + +The first mechanism is an explicit review-only module/component boundary selected +at build time. It relies on normal bundling, not arbitrary JSX, Vue, Svelte, or +Angular rewriting. + +Output tests inspect emitted JavaScript, CSS, assets, reachable chunks, and +published source maps. Mixed builds must retain production annotations. A no-op +adapter does not prove content exclusion. The website's checkout aliases cannot be +used to prove packed-package behavior. + +A Vite plugin is a post-release, evidence-gated convenience. It is retained only +if the plain boundary causes repeated setup errors and it produces the same tested +output. diff --git a/docs/delivery/evidence/README.md b/docs/delivery/evidence/README.md new file mode 100644 index 0000000..3ae8fa1 --- /dev/null +++ b/docs/delivery/evidence/README.md @@ -0,0 +1,29 @@ +# Delivery evidence + +Evidence summaries record what was actually checked without copying generated test +output into the repository. + +Use this template: + +```md +# evidence + +- Revision/base: +- Environment: +- Files changed: +- Acceptance criteria checked: +- Commands and exit status: +- Browser/manual artifacts: +- Pre-existing failures: +- Unverified items and reason: +- Reviewer decision: +``` + +Rules: + +- Never report an unrun or unavailable check as passing. +- Link retained artifacts where required; do not commit ordinary temporary output. +- Keep first-attempt failures distinct from corrected results. +- A visual snapshot does not prove behavior, and an annotation does not prove QA. +- Simulated users do not count as adoption or retention evidence. +- External research, outreach, publication, and release require authorization. diff --git a/docs/delivery/tasks/AD-ANGULAR.md b/docs/delivery/tasks/AD-ANGULAR.md new file mode 100644 index 0000000..916a2bf --- /dev/null +++ b/docs/delivery/tasks/AD-ANGULAR.md @@ -0,0 +1,21 @@ +# AD-ANGULAR — Angular full control +## Outcome +Angular directives implement the adapter contract. **Parent:** B7. **State:** BLOCKED. +## Prerequisites +CONTRACT-02, CORE-06, CORE-07 accepted. +## Required reading +Adapter contract, `src/angular.ts`, generated Angular examples for reference, and pattern lifecycle/SSR tests. +## Allowed edits +`src/angular.ts` and directly relevant Angular pattern tests only. AGENT-02 owns shared generator sources. +## Exact change +Preserve render-safe attachment while adding handle notification, changed visibility, updates, and replacement. +## Acceptance criteria +Directive imports valid; input changes do not replay; awaitable handle available; destroy reports null; SSR/hydration unchanged. +## Verification +`npm run check`, `npm run test:patterns`. Template propagation belongs to AGENT-02. +## Forbidden scope +Field injection changes, new Angular package, hand-edited templates, dependencies, commit/push/publish. +## Stop conditions +Stop if accepted input/output syntax conflicts with Angular 20–21 compilation. +## Review evidence +Compile, lifecycle, and SSR outcomes. diff --git a/docs/delivery/tasks/AD-INTEGRATE.md b/docs/delivery/tasks/AD-INTEGRATE.md new file mode 100644 index 0000000..3623afb --- /dev/null +++ b/docs/delivery/tasks/AD-INTEGRATE.md @@ -0,0 +1,21 @@ +# AD-INTEGRATE — Verify cross-framework parity +## Outcome +Vanilla and all adapters expose equal released behavior. **Parent:** B7/F2. **State:** BLOCKED. +## Prerequisites +All AD tasks accepted. +## Required reading +Adapter/runtime contracts, all adapter diffs, generator/template checks. +## Allowed edits +Integration tests and generator sources needed to correct proven parity gaps. +## Exact change +Run one equivalent visibility, callback, update, replay, cancellation, and replacement scenario per framework. +## Acceptance criteria +No adapter lacks public control; callbacks absent from plans; templates compile against actual exports. +## Verification +`npm run check`, `npm run test:templates`, `npm run test:patterns`, focused adapter tests. +## Forbidden scope +New behavior, unrelated refactors, direct generated edits, commit/push/publish. +## Stop conditions +Stop and return a specific adapter task on nonmechanical divergence. +## Review evidence +Parity matrix and commands. diff --git a/docs/delivery/tasks/AD-REACT.md b/docs/delivery/tasks/AD-REACT.md new file mode 100644 index 0000000..4376ad2 --- /dev/null +++ b/docs/delivery/tasks/AD-REACT.md @@ -0,0 +1,21 @@ +# AD-REACT — React full control +## Outcome +React implements the adapter contract. **Parent:** B7. **State:** BLOCKED. +## Prerequisites +CONTRACT-02, CORE-06, CORE-07 accepted. +## Required reading +Adapter contract, `src/react.ts`, `tests/react.test.ts`, and generated React examples for reference. +## Allowed edits +`src/react.ts` and `tests/react.test.ts` only. AGENT-02 owns shared generator sources. +## Exact change +Add React-18-compatible `onHandle`, changed-only visibility control, nonreplaying updates, and target replacement. +## Acceptance criteria +StrictMode cleanup; callback identity no remount; stable ref with replaced node detected; unrelated renders preserve imperative state; callback receives handle/null in approved order. +## Verification +`npx vitest run tests/react.test.ts`, `npm run check`, relevant pattern test. Template propagation belongs to AGENT-02. +## Forbidden scope +Runtime redesign, React-19-only API, hand-edited templates, dependencies, commit/push/publish. +## Stop conditions +Stop if accepted callback syntax does not compile on supported React. +## Review evidence +Lifecycle assertions, generated diff, commands. diff --git a/docs/delivery/tasks/AD-SVELTE.md b/docs/delivery/tasks/AD-SVELTE.md new file mode 100644 index 0000000..3f69831 --- /dev/null +++ b/docs/delivery/tasks/AD-SVELTE.md @@ -0,0 +1,21 @@ +# AD-SVELTE — Svelte full control +## Outcome +Svelte actions implement the adapter contract. **Parent:** B7. **State:** BLOCKED. +## Prerequisites +CONTRACT-02, CORE-06, CORE-07 accepted. +## Required reading +Adapter contract, `src/svelte.ts`, `tests/svelte.test.ts`, and generated Svelte examples for reference. +## Allowed edits +`src/svelte.ts` and `tests/svelte.test.ts` only. AGENT-02 owns shared generator sources. +## Exact change +Use action update/destroy for handle notification, visibility, updates, and replacement. +## Acceptance criteria +Callback identity no recreation; destroy reports null; async control uses supplied handle; no Svelte-specific controller. +## Verification +`npx vitest run tests/svelte.test.ts`, `npm run check`, relevant pattern test. Template propagation belongs to AGENT-02. +## Forbidden scope +Runtime redesign, hand-edited templates, dependencies, commit/push/publish. +## Stop conditions +Stop if accepted action type does not compile under supported Svelte. +## Review evidence +Action lifecycle cases and commands. diff --git a/docs/delivery/tasks/AD-VUE.md b/docs/delivery/tasks/AD-VUE.md new file mode 100644 index 0000000..f243150 --- /dev/null +++ b/docs/delivery/tasks/AD-VUE.md @@ -0,0 +1,21 @@ +# AD-VUE — Vue full control +## Outcome +Vue directives implement the adapter contract. **Parent:** B7. **State:** BLOCKED. +## Prerequisites +CONTRACT-02, CORE-06, CORE-07 accepted. +## Required reading +Adapter contract, `src/vue.ts`, `tests/vue.test.ts`, and generated Vue examples for reference. +## Allowed edits +`src/vue.ts` and `tests/vue.test.ts` only. AGENT-02 owns shared generator sources. +## Exact change +Use directive mounted/updated/unmounted lifecycle for `onHandle`, changed visibility, updates, and replacement. +## Acceptance criteria +Callback never serializes; identity changes do not remount; unmount reports null; unchanged reactive values preserve imperative state. +## Verification +`npx vitest run tests/vue.test.ts`, `npm run check`, relevant pattern test. Template propagation belongs to AGENT-02. +## Forbidden scope +Runtime redesign, new Vue abstraction, hand-edited templates, dependencies, commit/push/publish. +## Stop conditions +Stop if accepted binding shape conflicts with supported Vue directive typing. +## Review evidence +Directive lifecycle cases and commands. diff --git a/docs/delivery/tasks/AGENT-01.md b/docs/delivery/tasks/AGENT-01.md new file mode 100644 index 0000000..86e5ee8 --- /dev/null +++ b/docs/delivery/tasks/AGENT-01.md @@ -0,0 +1,23 @@ +# AGENT-01 — Keep generated contracts current +## Outcome +The complete first-release surface has matching installed agent facts. **Parent:** F1. **State:** BLOCKED/final audit. +## Prerequisites +AGENT-02 and PW-03 accepted. Public tasks still generate their owned artifacts +when required; this task audits the integrated result after the final shared +generator writer. +## Required reading +`agent/catalog.mjs`, `scripts/agent/generate.mjs`, schema/drift tests and changed API. +## Allowed edits +Catalog/generator/snippet sources, focused tests, generated outputs via generation. +## Exact change +Represent released primitives/options/defaults/restrictions; keep adapter callbacks out of plans. +## Acceptance criteria +Runtime and plan validation agree; package export checks remain valid; old plans have explicit compatibility. +## Verification +`npm run check`, `npm run test:agent`, `npm run test:templates`, `npm run test:package` as relevant. +## Forbidden scope +Hand-edited generated files, speculative timeline schema, commit/push/publish. +## Stop conditions +Stop if generator cannot express an approved public type. +## Review evidence +Source and generated diff with drift results. diff --git a/docs/delivery/tasks/AGENT-02.md b/docs/delivery/tasks/AGENT-02.md new file mode 100644 index 0000000..63fca87 --- /dev/null +++ b/docs/delivery/tasks/AGENT-02.md @@ -0,0 +1,22 @@ +# AGENT-02 — Add canonical motion recipes +## Outcome +Agents use real static, show, hide, replay and cancellation-aware APIs in every framework. **Parent:** F2. **State:** BLOCKED. +## Prerequisites +AD-INTEGRATE, CORE-05, and BOX-02 accepted. This task is the final planned writer +of shared snippet/pattern sources before AGENT-01 audits them. +## Required reading +Agent snippets/patterns, adapter/runtime contracts and template checker. +## Allowed edits +`agent/snippets.mjs`, `agent/patterns.mjs`, relevant tests; templates only through generation. +## Exact change +Generate static, hidden, reveal, replay and host-code sequencing examples using `onHandle` where applicable. +## Acceptance criteria +Cancelled sequence stops; reduced motion/cleanup included; no group/timeline API invented. +## Verification +`npm run check`, `npm run test:agent`, `npm run test:templates`, relevant pattern tests. +## Forbidden scope +Hand-edited templates, new orchestration schema, commit/push/publish. +## Stop conditions +Stop if one framework cannot demonstrate the accepted contract. +## Review evidence +Compiled generated examples and behavior cases. diff --git a/docs/delivery/tasks/AGENT-03.md b/docs/delivery/tasks/AGENT-03.md new file mode 100644 index 0000000..2503d68 --- /dev/null +++ b/docs/delivery/tasks/AGENT-03.md @@ -0,0 +1,21 @@ +# AGENT-03 — Teach source versus injection +## Outcome +Skills choose delivery by intended lifetime. **Parent:** F3. **State:** BLOCKED. +## Prerequisites +PW-06 and SHIP-03 accepted. +## Required reading +Existing Stet skills, targeting/handoff/verification references, Playwright/shipping contracts. +## Allowed edits +Relevant skill sources and their tests. +## Exact change +Route durable/published explanations to source and temporary artifacts to injection; surface ambiguity and limits. +## Acceptance criteria +No claim Stet runs agents, edits automatically, or performs QA; unsupported contexts stated. +## Verification +Positive, negative and ambiguous skill/routing fixtures; `npm run test:agent`. +## Forbidden scope +Runtime/helper changes, automatic external actions, commit/push/publish. +## Stop conditions +Stop if delivery choice needs product information absent from the request and no clarification path exists. +## Review evidence +Routing cases and retained outputs. diff --git a/docs/delivery/tasks/BASE-01.md b/docs/delivery/tasks/BASE-01.md new file mode 100644 index 0000000..890b20a --- /dev/null +++ b/docs/delivery/tasks/BASE-01.md @@ -0,0 +1,24 @@ +# BASE-01 — Record the baseline + +## Outcome +Reproducible revision, environment, test, browser, and size evidence before implementation. +## Parent +A1, A5, H2. **State: READY.** +## Prerequisites +None. +## Required reading +`package.json`, lockfile, test configs, workflow files, `scripts-size.mjs`. +## Allowed edits +One evidence summary under `docs/delivery/evidence/`; no product files. +## Exact change +Record revision/tree changes, Node/npm versions, browser availability, current checks, and payload measurements. Separate code failures from environment failures. +## Acceptance criteria +A later task can attribute new failures; every unavailable check is explicit; unrelated failures remain unchanged. +## Verification +Run the existing commands appropriate to the installed environment and record exact status. +## Forbidden scope +Fixing failures, installing tools, changing snapshots/budgets, commit/push/publish. +## Stop conditions +Stop if a command would mutate dependencies or overwrite evidence. +## Review evidence +Command transcript summary, environment, pre-existing failures, unverified checks. diff --git a/docs/delivery/tasks/BOX-01.md b/docs/delivery/tasks/BOX-01.md new file mode 100644 index 0000000..1f5fa30 --- /dev/null +++ b/docs/delivery/tasks/BOX-01.md @@ -0,0 +1,21 @@ +# BOX-01 — Add deterministic box geometry +## Outcome +Internal rough-box geometry reuses stroked reveal. **Parent:** C1. **State:** BLOCKED. +## Prerequisites +CORE-06 accepted, so no concurrent writer is changing `src/rough.ts`. +## Required reading +`src/rough.ts`, existing ellipse/line geometry and tests, runtime contract. +## Allowed edits +`src/rough.ts` and focused geometry/browser visual tests. +## Exact change +Add only the geometry needed for box, including small/zero measured regions. +## Acceptance criteria +Stable seed; defined padding/roughness; accepted final and reveal path; no generic shape abstraction. +## Verification +Focused Vitest, browser visual fixture, manual review. +## Forbidden scope +Public exports/adapters/catalog, dependencies, snapshot acceptance without review, commit/push/publish. +## Stop conditions +Stop if path structure cannot use accepted reveal semantics. +## Review evidence +Determinism and visual artifact. diff --git a/docs/delivery/tasks/BOX-02.md b/docs/delivery/tasks/BOX-02.md new file mode 100644 index 0000000..bcaced3 --- /dev/null +++ b/docs/delivery/tasks/BOX-02.md @@ -0,0 +1,22 @@ +# BOX-02 — Publish box everywhere +## Outcome +Box is complete across runtime, adapters, agent contracts, templates, and package. **Parent:** C1/F1. **State:** BLOCKED. +## Prerequisites +BOX-01 and AD-INTEGRATE accepted. +## Required reading +All primitive exports/adapters, `agent/catalog.mjs`, generators, package checks. +## Allowed edits +Primitive/index/adapters, catalog/snippet/pattern sources, tests, generated outputs via generation. +## Exact change +Add named box symbols and static/animated plan support following existing primitive +patterns, and register box in the CORE-02 reveal-support mechanism. +## Acceptance criteria +Export cardinality, tree-shaking, adapter imports, schema validation and packed consumers pass. +## Verification +`npm test`, `npm run check`, `npm run test:templates`, `npm run test:package`, targeted browser test. +## Forbidden scope +Universal factory, direct generated edits, other primitives, dependencies, commit/push/publish. +## Stop conditions +Stop if any framework cannot expose the agreed symbol without redesign. +## Review evidence +Public surface matrix, generated diff, commands. diff --git a/docs/delivery/tasks/CONTRACT-01.md b/docs/delivery/tasks/CONTRACT-01.md new file mode 100644 index 0000000..64b8dcb --- /dev/null +++ b/docs/delivery/tasks/CONTRACT-01.md @@ -0,0 +1,24 @@ +# CONTRACT-01 — Finalize runtime transitions + +## Outcome +An implementation-ready state table matching `contracts/runtime.md`. +## Parent +A3. **State: BLOCKED.** +## Prerequisites +Accepted BASE-01. +## Required reading +Runtime contract, `src/mount.ts`, `src/primitives.ts`, `style.css`, runtime/browser tests. +## Allowed edits +`docs/delivery/contracts/runtime.md` and task evidence only. +## Exact change +Specify promise identity, overlap, redraw during reveal, ARIA ownership, offscreen/detached/destroyed behavior, and a test for every transition. +## Acceptance criteria +No worker must choose lifecycle behavior; arrow/sticky wrappers and static compatibility are covered. +## Verification +Independent contract review against every `StetHandle` caller. +## Forbidden scope +Runtime implementation, new animation features, commit/push/publish. +## Stop conditions +Stop on a product decision not covered by the approved contract. +## Review evidence +Reviewed state table and test mapping. diff --git a/docs/delivery/tasks/CONTRACT-02.md b/docs/delivery/tasks/CONTRACT-02.md new file mode 100644 index 0000000..ff5f212 --- /dev/null +++ b/docs/delivery/tasks/CONTRACT-02.md @@ -0,0 +1,24 @@ +# CONTRACT-02 — Finalize adapter/update design + +## Outcome +Compiled, idiomatic adapter signatures and a minimal nonreplaying internal update design. +## Parent +A4 and required B6 subset. **State: BLOCKED.** +## Prerequisites +Accepted CONTRACT-01. +## Required reading +Adapter contract; `src/react.ts`, `src/vue.ts`, `src/svelte.ts`, `src/angular.ts`; adapter/pattern tests. +## Allowed edits +`docs/delivery/contracts/adapters.md`, proposed compile-only contract fixtures, evidence. +## Exact change +Fix `onHandle` syntax/ordering and visibility/update/target-replacement behavior for all adapters. +## Acceptance criteria +React 18+, Vue directive, Svelte action, and Angular directive examples compile; callbacks never serialize; no lifecycle question remains. +## Verification +Narrow framework compilation and independent API review. +## Forbidden scope +Production implementation, controller registry, public generic update API, commit/push/publish. +## Stop conditions +Stop if one callback shape cannot be expressed idiomatically in a supported adapter. +## Review evidence +Exact types/examples and compilation outcomes. diff --git a/docs/delivery/tasks/CONTRACT-03.md b/docs/delivery/tasks/CONTRACT-03.md new file mode 100644 index 0000000..6eda791 --- /dev/null +++ b/docs/delivery/tasks/CONTRACT-03.md @@ -0,0 +1,24 @@ +# CONTRACT-03 — Finalize Playwright API + +## Outcome +Exact helper, remote-handle, ownership, navigation, frame, and error contracts. +## Parent +D1. **State: BLOCKED.** +## Prerequisites +Accepted CONTRACT-01. +## Required reading +Playwright contract, package exports/checks, Playwright configs and existing browser fixtures. +## Allowed edits +`docs/delivery/contracts/playwright.md` and evidence only. +## Exact change +Specify `createStet(Page|Frame)`, primitive calls, result methods, target races, document ownership, cleanup, and error taxonomy. +## Acceptance criteria +No loader assumption; normal cancellation differs from browser failure; unsupported contexts are explicit. +## Verification +Independent contract and package-boundary review. +## Forbidden scope +Injection implementation, CSP claims without evidence, commit/push/publish. +## Stop conditions +Stop if API depends on unknown PW-01/PW-02 results; leave only that mechanism evidence-gated. +## Review evidence +Final signatures, errors, and acceptance matrix. diff --git a/docs/delivery/tasks/CONTRACT-04.md b/docs/delivery/tasks/CONTRACT-04.md new file mode 100644 index 0000000..85f5d37 --- /dev/null +++ b/docs/delivery/tasks/CONTRACT-04.md @@ -0,0 +1,24 @@ +# CONTRACT-04 — Finalize shipping guarantees + +## Outcome +Exact production, preview-only, local-only, disabled, mixed, and source-map output matrix. +## Parent +E1. **State: BLOCKED.** +## Prerequisites +Accepted BASE-01 and CONTRACT-02. +## Required reading +Shipping contract, Vite configs, `package.json` side effects, package checks. +## Allowed edits +`docs/delivery/contracts/shipping.md` and evidence only. +## Exact change +Specify every mode's module ownership, build constants, emitted artifacts, and unsupported configurations. +## Acceptance criteria +Each disabled/runtime/content guarantee is falsifiable; production marks survive mixed mode. +## Verification +Independent bundler/package review. +## Forbidden scope +Universal AST stripping, Vite plugin implementation, commit/push/publish. +## Stop conditions +Stop if a guarantee cannot be tested at emitted-output level. +## Review evidence +Output matrix and proposed sentinel checks. diff --git a/docs/delivery/tasks/CONTRACT-05.md b/docs/delivery/tasks/CONTRACT-05.md new file mode 100644 index 0000000..9d94d54 --- /dev/null +++ b/docs/delivery/tasks/CONTRACT-05.md @@ -0,0 +1,24 @@ +# CONTRACT-05 — Audit the task graph + +## Outcome +No hidden or circular prerequisite in the first-release graph. +## Parent +Release planning. **State: BLOCKED.** +## Prerequisites +Accepted CONTRACT-01 through CONTRACT-04. +## Required reading +All contracts, `backlog.md`, required task packets, current repository layout. +## Allowed edits +Delivery documentation only. +## Exact change +Confirm classification, dependencies, generated-output owners, and exclusive shared-file writers. +## Acceptance criteria +Every release requirement has one implementation and verification owner; injection does not wait on optional work. +## Verification +Independent graph review and relative-link check. +## Forbidden scope +Product implementation or new roadmap scope. +## Stop conditions +Stop and reopen the relevant contract on a hidden design decision. +## Review evidence +Dependency corrections and accepted release critical path. diff --git a/docs/delivery/tasks/CORE-01.md b/docs/delivery/tasks/CORE-01.md new file mode 100644 index 0000000..9d373b0 --- /dev/null +++ b/docs/delivery/tasks/CORE-01.md @@ -0,0 +1,24 @@ +# CORE-01 — Add visibility and ARIA ownership + +## Outcome +All six current primitives implement the approved explicit visibility contract. +## Parent +B1. **State: BLOCKED.** +## Prerequisites +Accepted CONTRACT-01. +## Required reading +Runtime contract; `src/mount.ts`, `src/primitives.ts`, `tests/stet.test.ts`, scroll/browser tests. +## Allowed edits +Those runtime/test files and generated contract sources/outputs required by public options. +## Exact change +Separate author intent from culling; implement show/hide; toggle all owned descriptions; forward full handles through arrow/sticky. +## Acceptance criteria +Hide survives observer changes; native/foreign ARIA remains; show restores owned IDs; cleanup is idempotent; static default remains. +## Verification +Focused Vitest, `npm run check`, targeted browser/scroll test. +## Forbidden scope +Reveal CSS, adapter redesign, unrelated geometry, hand-edited generated output, commit/push/publish. +## Stop conditions +Stop if implementation contradicts the accepted transition table. +## Review evidence +State/ARIA cases and command outcomes. diff --git a/docs/delivery/tasks/CORE-02.md b/docs/delivery/tasks/CORE-02.md new file mode 100644 index 0000000..4ddf8cb --- /dev/null +++ b/docs/delivery/tasks/CORE-02.md @@ -0,0 +1,26 @@ +# CORE-02 — Validate animation options + +## Outcome +Runtime and plans agree on flat options, inferred opt-in, defaults, and supported primitives. +## Parent +B2/F1. **State: BLOCKED.** +## Prerequisites +Accepted CORE-01. +## Required reading +Runtime contract, generator/catalog, schema/drift tests, primitive attach paths. +## Allowed edits +Runtime validation, catalog/generator sources, relevant tests, generated outputs through generation. +## Exact change +Add options and an explicit reveal-support check for circle/underline. Reject +invalid numbers or unsupported animated primitives before mutation. BOX-02 extends +the same support check when box becomes public. +## Acceptance criteria +`animate:false` overrides timing; defaults are accurate; runtime/plan validation agree; no nested option type. +## Verification +Focused runtime and agent schema tests, `npm run check`. +## Forbidden scope +Animation rendering, schema hand edits, generator object support, commit/push/publish. +## Stop conditions +Stop if generated contracts cannot express approved semantics without a contract change. +## Review evidence +Validation table and generated diff. diff --git a/docs/delivery/tasks/CORE-03.md b/docs/delivery/tasks/CORE-03.md new file mode 100644 index 0000000..9d07712 --- /dev/null +++ b/docs/delivery/tasks/CORE-03.md @@ -0,0 +1,28 @@ +# CORE-03 — Implement underline reveal + +## Outcome +The first opt-in finite stroked entrance. +## Parent +B2. **State: BLOCKED.** +## Prerequisites +Accepted CORE-02. +## Required reading +Runtime contract, mount/path creation, underline geometry, CSS and visual tests. +## Allowed edits +Shared reveal mechanism, underline integration, CSS, focused unit/browser tests. +## Exact change +Implement a path-length draw-on using approved duration/delay/initial-hidden +behavior without a pre-animation flash. Ensure viewport culling cannot prevent +logical completion and that an annotation unculled after completion displays its +final frame. +## Acceptance criteria +Correct draw-on and final state; refresh/scroll/resize do not replay; static +underline remains compatible; offscreen completion follows the runtime contract. +## Verification +Focused Vitest, targeted Playwright reveal test, manual visual review. +## Forbidden scope +Other primitive treatments, snapshots without review, dependency/budget changes, commit/push/publish. +## Stop conditions +Stop if completion semantics require unapproved API changes. +## Review evidence +Timing/state assertions and visual artifact. diff --git a/docs/delivery/tasks/CORE-04.md b/docs/delivery/tasks/CORE-04.md new file mode 100644 index 0000000..c0d1e34 --- /dev/null +++ b/docs/delivery/tasks/CORE-04.md @@ -0,0 +1,24 @@ +# CORE-04 — Implement circle reveal + +## Outcome +Circle reuses the accepted stroked reveal mechanism. +## Parent +B2. **State: BLOCKED.** +## Prerequisites +Accepted CORE-03. +## Required reading +Circle geometry, accepted underline mechanism and tests. +## Allowed edits +Circle integration and corresponding focused tests. +## Exact change +Apply the shared reveal without a second controller. +## Acceptance criteria +Same lifecycle as underline; geometry refresh does not replay; settled appearance stays compatible. +## Verification +Focused Vitest/browser tests and manual visual review. +## Forbidden scope +Generic shape abstraction, unrelated primitives, budget/snapshot changes, commit/push/publish. +## Stop conditions +Stop if the shared mechanism cannot preserve circle geometry. +## Review evidence +Parity cases and visual artifact. diff --git a/docs/delivery/tasks/CORE-05.md b/docs/delivery/tasks/CORE-05.md new file mode 100644 index 0000000..c3fc005 --- /dev/null +++ b/docs/delivery/tasks/CORE-05.md @@ -0,0 +1,25 @@ +# CORE-05 — Add completion, replay, cancellation + +## Outcome +The approved asynchronous state machine works for underline and circle and is +reusable by later supported reveal primitives. +## Parent +B4. **State: BLOCKED.** +## Prerequisites +Accepted CONTRACT-01 and CORE-04. +## Required reading +Runtime contract, current reveal implementation, all handle wrappers/callers. +## Allowed edits +Shared runtime/result exports, wrappers, focused unit/browser tests, generated type sources as required. +## Exact change +Implement promise joining, replay supersession, cancellation results, and destroyed behavior. +## Acceptance criteria +Operations settle exactly once; no listener/promise leak; cancellation never reports finished. +## Verification +Transition-table Vitest and real-browser interruption cases; `npm run check`. +## Forbidden scope +Groups, adapters, rejection-based normal cancellation, commit/push/publish. +## Stop conditions +Stop after two failed fixes for any race and report reproducible evidence. +## Review evidence +Every transition mapped to a passing check. diff --git a/docs/delivery/tasks/CORE-06.md b/docs/delivery/tasks/CORE-06.md new file mode 100644 index 0000000..dc0d919 --- /dev/null +++ b/docs/delivery/tasks/CORE-06.md @@ -0,0 +1,24 @@ +# CORE-06 — Compose reveal and ambient motion + +## Outcome +Reveal, boil, hover resketch, reduced motion, and capture settle coherently. +## Parent +B5. **State: BLOCKED.** +## Prerequisites +Accepted CORE-05. +## Required reading +Runtime contract motion rules, `src/rough.ts`, mount, CSS, reduced-motion/hover/browser tests. +## Allowed edits +Shared motion/render code, CSS and focused tests. +## Exact change +Implement one composition policy without variant flashes or implicit replay. +## Acceptance criteria +Live reduced-motion settles finished; hover does not replay; offscreen timing matches contract; deterministic capture is possible. +## Verification +Focused Vitest and browser tests for media, hover, scroll and stable capture. +## Forbidden scope +New ambient effects, new options, budget changes, commit/push/publish. +## Stop conditions +Stop if preserving current boil requires changing the approved API. +## Review evidence +Behavior matrix and visual artifacts. diff --git a/docs/delivery/tasks/CORE-07.md b/docs/delivery/tasks/CORE-07.md new file mode 100644 index 0000000..dc27a7f --- /dev/null +++ b/docs/delivery/tasks/CORE-07.md @@ -0,0 +1,24 @@ +# CORE-07 — Add internal nonreplaying updates + +## Outcome +Adapters can update supported data without recreating entrance state. +## Parent +Required B6 subset. **State: BLOCKED.** +## Prerequisites +Accepted CONTRACT-02 and CORE-06. +## Required reading +Adapter/update contract, mount/primitives, description and option snapshots. +## Allowed edits +Approved internal runtime interfaces and focused tests. +## Exact change +Update appearance, text, placement, visibility and next-entrance settings per contract. +## Acceptance criteria +Same values no-op; current visibility/seed preserved; description updates safe; no public generic `update()`. +## Verification +Focused runtime update matrix and `npm run check`. +## Forbidden scope +Adapter edits, public update API, broad renderer refactor, commit/push/publish. +## Stop conditions +Stop if a supported field cannot update without changing public behavior. +## Review evidence +Per-field outcomes and commands. diff --git a/docs/delivery/tasks/CORE-08.md b/docs/delivery/tasks/CORE-08.md new file mode 100644 index 0000000..919c0f1 --- /dev/null +++ b/docs/delivery/tasks/CORE-08.md @@ -0,0 +1,25 @@ +# CORE-08 — Reconcile motion size budgets + +## Outcome +Accepted measured budgets after motion, without hidden leakage. +## Parent +A5. **State: BLOCKED.** +## Prerequisites +Accepted MEASURE-01, CORE-06, and BOX-02 so the budget includes every required +core primitive. +## Required reading +Measurement evidence, package graph, product size goals. +## Allowed edits +Only lead-approved optimizations or explicit budget/documentation updates. +## Exact change +Measure deltas, remove accidental inclusion, and record the approved ceiling decision. +## Acceptance criteria +Minimal/full deltas recorded; tree-shaking remains; every limit change is explicit. +## Verification +`npm run size`, `npm run test:package`. +## Forbidden scope +Silent limit increase, feature deletion without product approval, commit/push/publish. +## Stop conditions +Worker stops for lead decision after producing measurements. +## Review evidence +Before/after bundle graph and decision. diff --git a/docs/delivery/tasks/DOCS-01.md b/docs/delivery/tasks/DOCS-01.md new file mode 100644 index 0000000..6c9ae89 --- /dev/null +++ b/docs/delivery/tasks/DOCS-01.md @@ -0,0 +1,23 @@ +# DOCS-01 — Publish compatibility and migration reference +## Outcome +Users can qualify the first-release Stet surface without inferred promises. **Parent:** A2/G4. **State:** BLOCKED. +## Prerequisites +BOX-02, PW-06, and SHIP-03 accepted. +## Required reading +Runtime/integration contracts, tests, Rough Notation current API, existing reference docs. +## Allowed edits +Compatibility/migration/reference docs and snippet checks. +## Exact change +Document current behavior, static default, the released Rough Notation subset, +browser/frame/CSP/accessibility/placement and alternative boundaries. Full C1–C6 +migration coverage remains follow-on work. +## Acceptance criteria +Current versus future explicit; snippets compile; every capability claim traces to test/evidence. +## Verification +Snippet consumer and independent claim review. +## Forbidden scope +Competitor overclaims, undocumented future APIs, commit/push/publish. +## Stop conditions +Stop if a claimed comparison cannot be sourced or demonstrated. +## Review evidence +Claim/source/test table. diff --git a/docs/delivery/tasks/EVIDENCE-01.md b/docs/delivery/tasks/EVIDENCE-01.md new file mode 100644 index 0000000..2ac5059 --- /dev/null +++ b/docs/delivery/tasks/EVIDENCE-01.md @@ -0,0 +1,21 @@ +# EVIDENCE-01 — Measure the private funnel +## Outcome +Aggregate intent without runtime surveillance. **Parent:** G5. **State:** BLOCKED/human-approved. +## Prerequisites +Approved privacy/event definitions and first-success paths. +## Required reading +Website analytics, privacy policy and product metrics. +## Allowed edits +Approved website events/tests/docs only. +## Exact change +Measure GitHub, install, starter, playground and agent-guide actions without content. +## Acceptance criteria +No code/selectors/text/forms; local excluded; intent never labeled installation; runtime/CLI telemetry-free. +## Verification +Production-origin event tests and privacy review. +## Forbidden scope +Fingerprinting, runtime analytics, external deployment, commit/push/publish. +## Stop conditions +Stop without explicit analytics/privacy authorization. +## Review evidence +Event dictionary and payload samples. diff --git a/docs/delivery/tasks/EVIDENCE-02.md b/docs/delivery/tasks/EVIDENCE-02.md new file mode 100644 index 0000000..7a8d467 --- /dev/null +++ b/docs/delivery/tasks/EVIDENCE-02.md @@ -0,0 +1,21 @@ +# EVIDENCE-02 — Open contribution pathways +## Outcome +Bounded reproductions and examples can be submitted. **Parent:** G6. **State:** BLOCKED/human-approved. +## Prerequisites +One accepted exemplar and authorization for repository settings/templates. +## Required reading +Contribution docs, GitHub templates/settings and release compatibility fields. +## Allowed edits +Approved issue templates/docs; settings only by authorized human. +## Exact change +Request versions, context, expected behavior, reproduction and example permission. +## Acceptance criteria +No generic placeholders; external example publication requires consent. +## Verification +Dry-run complete issue. +## Forbidden scope +Automatic outreach/settings changes without authorization, commit/push/publish. +## Stop conditions +Stop before any external action. +## Review evidence +Completed dry run. diff --git a/docs/delivery/tasks/EVIDENCE-03.md b/docs/delivery/tasks/EVIDENCE-03.md new file mode 100644 index 0000000..caf76d1 --- /dev/null +++ b/docs/delivery/tasks/EVIDENCE-03.md @@ -0,0 +1,21 @@ +# EVIDENCE-03 — Compare live and screenshot explanations +## Outcome +Test whether interaction improves understanding. **Parent:** H1/experiment 2. **State:** BLOCKED/human-led. +## Prerequisites +Accepted CORE-03 prototype and recruited participants. +## Required reading +Demand experiment protocol and matched example. +## Allowed edits +Consented research materials and anonymized summary. +## Exact change +Present equivalent screenshot/prose and live annotation conditions. +## Acceptance criteria +Record accuracy, time, missed behavior and preference; results guide expensive treatments. +## Verification +Protocol review and raw-to-summary audit. +## Forbidden scope +Simulated participants, leading copy, public outreach/publication without authorization. +## Stop conditions +Stop without consent or comparable information. +## Review evidence +Anonymized observations and decision. diff --git a/docs/delivery/tasks/EVIDENCE-04.md b/docs/delivery/tasks/EVIDENCE-04.md new file mode 100644 index 0000000..10a95c2 --- /dev/null +++ b/docs/delivery/tasks/EVIDENCE-04.md @@ -0,0 +1,21 @@ +# EVIDENCE-04 — Run docs and Storybook pilot +## Outcome +Measure first success, retention and second use. **Parent:** H1/experiments 1 and 3. **State:** BLOCKED/human-led. +## Prerequisites +Accepted relevant starter and recruited external projects. +## Required reading +Experiment protocol, starter and metrics definitions. +## Allowed edits +Consented research materials and anonymized evidence. +## Exact change +Observe real installations and follow up after 30 days. +## Acceptance criteria +Maintainer demos excluded; abandonment reasons retained; second use independently identified. +## Verification +Evidence ledger and follow-up dates. +## Forbidden scope +Fabricated retention, unsolicited outreach, public naming without consent. +## Stop conditions +Stop before external contact without authorization. +## Review evidence +Activation/retention table. diff --git a/docs/delivery/tasks/EVIDENCE-05.md b/docs/delivery/tasks/EVIDENCE-05.md new file mode 100644 index 0000000..8364e0b --- /dev/null +++ b/docs/delivery/tasks/EVIDENCE-05.md @@ -0,0 +1,21 @@ +# EVIDENCE-05 — Compare agent handoffs +## Outcome +Compare ordinary, source-authored and injected delivery. **Parent:** F5/H1/experiment 4. **State:** BLOCKED/human-led. +## Prerequisites +Accepted HANDOFF-01 and recruited agent users. +## Required reading +Evaluation protocol, retained failures and verification guidance. +## Allowed edits +Consented trials and evaluation summary/evidence. +## Exact change +Run matched tasks and record correction effort, false claims, accuracy, setup and reuse. +## Acceptance criteria +Initial/corrected separate; ordinary baseline equal requirements; live interaction actually observed. +## Verification +Independent artifact scoring and replay. +## Forbidden scope +Generalized model claims, simulated users, external publication without approval. +## Stop conditions +Stop if tasks/outputs are not comparable. +## Review evidence +Raw artifact links and scoped conclusion. diff --git a/docs/delivery/tasks/EVIDENCE-06.md b/docs/delivery/tasks/EVIDENCE-06.md new file mode 100644 index 0000000..b85b66a --- /dev/null +++ b/docs/delivery/tasks/EVIDENCE-06.md @@ -0,0 +1,21 @@ +# EVIDENCE-06 — Test preview exclusion adoption +## Outcome +Determine whether the plain boundary is sufficient. **Parent:** Experiment 6. **State:** BLOCKED/human-led. +## Prerequisites +SHIP-03 and authorized pilot teams. +## Required reading +Shipping contract, fixture and output evidence. +## Allowed edits +Consented pilot notes and anonymized summary. +## Exact change +Have teams implement the recipe and inspect actual production output. +## Acceptance criteria +Setup errors recorded; plugin requires repeated friction; no flag-only success. +## Verification +Per-pilot output scan. +## Forbidden scope +Building plugin during research, unsolicited outreach, publication without consent. +## Stop conditions +Stop if output cannot be inspected. +## Review evidence +Integration outcomes and plugin decision. diff --git a/docs/delivery/tasks/EVIDENCE-07.md b/docs/delivery/tasks/EVIDENCE-07.md new file mode 100644 index 0000000..f63d42d --- /dev/null +++ b/docs/delivery/tasks/EVIDENCE-07.md @@ -0,0 +1,21 @@ +# EVIDENCE-07 — Test Rough Notation migration +## Outcome +Find a noncosmetic switching reason. **Parent:** Experiment 5. **State:** BLOCKED/human-led. +## Prerequisites +Accepted comparison and authorized current users. +## Required reading +Migration reference and comparable released examples. +## Allowed edits +Consented research materials and anonymized summary. +## Exact change +Evaluate Stet in real Rough Notation/custom-overlay projects. +## Acceptance criteria +Retained switch names lifecycle/workflow benefit; rejection reasons preserved. +## Verification +Real project evidence and 30-day follow-up. +## Forbidden scope +Issue-thread promotion, star/download inference, publication without consent. +## Stop conditions +Stop if only aesthetic preference is measured. +## Review evidence +Switch/decline rationale. diff --git a/docs/delivery/tasks/EVIDENCE-08.md b/docs/delivery/tasks/EVIDENCE-08.md new file mode 100644 index 0000000..a39954d --- /dev/null +++ b/docs/delivery/tasks/EVIDENCE-08.md @@ -0,0 +1,21 @@ +# EVIDENCE-08 — Publish evaluation and decide continuation +## Outcome +Make continue/pivot/maintenance decision from observed use. **Parent:** F6 and 30/60/90 gates. **State:** BLOCKED/human-led. +## Prerequisites +Relevant evidence tasks and follow-up windows complete. +## Required reading +Product stop criteria, raw evidence, public signal limitations. +## Allowed edits +Evaluation report and decision record; publication requires authorization. +## Exact change +Separate fresh execution, replay, first attempt, correction and retained use. +## Acceptance criteria +Sample/limits explicit; simulated users excluded; stars/raw downloads secondary; thresholds applied honestly. +## Verification +Independent evidence-to-conclusion review. +## Forbidden scope +Cherry-picking, unsupported market/model claims, publishing without approval. +## Stop conditions +Stop if required follow-up data is missing; report incomplete instead. +## Review evidence +Decision with evidence links and dissent/uncertainty. diff --git a/docs/delivery/tasks/FIX-01.md b/docs/delivery/tasks/FIX-01.md new file mode 100644 index 0000000..8514e34 --- /dev/null +++ b/docs/delivery/tasks/FIX-01.md @@ -0,0 +1,25 @@ +# FIX-01 — Align stable website examples + +## Outcome +Stable site runtime and copied snippets match the advertised package. +## Parent +A1. **State: BLOCKED.** +## Prerequisites +Accepted BASE-01 and lead-approved parity approach. +## Required reading +`website/vite.config.ts`, `website/src/constants.ts`, `website/src/components/Playground.tsx`, `website/src/showcase/canonical.ts`, website checks. +## Allowed edits +Listed website files and directly relevant website tests. +## Exact change +Remove stable dependence on checkout-only capabilities or isolate a visibly separate preview. +## Acceptance criteria +Stable snippets compile against the displayed release; zero-valued unreleased options cannot leak; labels agree. +## Verification +`npm --prefix website run check`, `npm --prefix website test`, and a fresh +released-package snippet consumer. +## Forbidden scope +Feature work, redesign, package publication, commit/push. +## Stop conditions +Stop if the approved approach requires changing the published package. +## Review evidence +Before/after snippet, package version, commands. diff --git a/docs/delivery/tasks/FIX-02.md b/docs/delivery/tasks/FIX-02.md new file mode 100644 index 0000000..f6b01fd --- /dev/null +++ b/docs/delivery/tasks/FIX-02.md @@ -0,0 +1,24 @@ +# FIX-02 — Canonicalize installation + +## Outcome +One tested scoped-package, CSS, ESM, CLI, and Node installation path. +## Parent +A6. **State: BLOCKED.** +## Prerequisites +Accepted FIX-01. +## Required reading +README, install/agent docs, package metadata, CLI consumer check, website install copy. +## Allowed edits +Installation prose/tests and directly corresponding website copy. +## Exact change +Distinguish scoped install from local `stet` binary and CLI Node from website-development Node requirements. +## Acceptance criteria +Every command has context and is exercised by a fresh consumer. +## Verification +`npm run test:package`, `npm run test:cli-consumer` with the documented artifact. +## Forbidden scope +Renaming package/binary, changing Node floors without evidence, commit/push/publish. +## Stop conditions +Stop if existing release artifacts cannot support the chosen instructions. +## Review evidence +Canonical sequence and consumer outcomes. diff --git a/docs/delivery/tasks/FOLLOW-01.md b/docs/delivery/tasks/FOLLOW-01.md new file mode 100644 index 0000000..974ba20 --- /dev/null +++ b/docs/delivery/tasks/FOLLOW-01.md @@ -0,0 +1,21 @@ +# FOLLOW-01 — Highlight sweep +## Outcome +Primitive-appropriate marker-wash reveal. **Parent:** B3. **State:** BLOCKED/post-release. +## Prerequisites +CORE-06 and user evidence for animated highlight. +## Required reading +Runtime contract motion rules, highlight geometry/CSS/tests. +## Allowed edits +Highlight render/style, adapters/contracts, focused tests. +## Exact change +Reveal the fill as a wash, not an outline trace. +## Acceptance criteria +Completion/reduced motion/refresh contracts preserved; final appearance compatible. +## Verification +Focused unit/browser tests and visual review. +## Forbidden scope +New general animation engine, dependencies, commit/push/publish. +## Stop conditions +Stop if no treatment works without changing settled output materially. +## Review evidence +Visual alternatives and accepted result. diff --git a/docs/delivery/tasks/FOLLOW-02.md b/docs/delivery/tasks/FOLLOW-02.md new file mode 100644 index 0000000..a771e50 --- /dev/null +++ b/docs/delivery/tasks/FOLLOW-02.md @@ -0,0 +1,21 @@ +# FOLLOW-02 — Arrow reveal +## Outcome +Intentional shaft, head, and label entrance. **Parent:** B3. **State:** BLOCKED/post-release. +## Prerequisites +CORE-06 and user evidence. +## Required reading +Runtime contract motion rules, arrow geometry/label/ARIA tests. +## Allowed edits +Arrow rendering/style, adapters/contracts and focused tests. +## Exact change +Sequence compound arrow parts without changing relationship semantics. +## Acceptance criteria +Label readable; refresh no replay; cancellation/reduced motion exact. +## Verification +Browser lifecycle/visual tests. +## Forbidden scope +Obstacle routing, cross-document arrows, commit/push/publish. +## Stop conditions +Stop if routing changes become necessary. +## Review evidence +Timing and visual artifact. diff --git a/docs/delivery/tasks/FOLLOW-03.md b/docs/delivery/tasks/FOLLOW-03.md new file mode 100644 index 0000000..5e2b218 --- /dev/null +++ b/docs/delivery/tasks/FOLLOW-03.md @@ -0,0 +1,21 @@ +# FOLLOW-03 — Sticky reveal +## Outcome +Coherent paper/text entrance. **Parent:** B3. **State:** BLOCKED/post-release. +## Prerequisites +CORE-06 and user evidence. +## Required reading +Sticky placement/text/ARIA and motion contracts. +## Allowed edits +Sticky render/style, adapters/contracts and focused tests. +## Exact change +Reveal note without arbitrary outline tracing. +## Acceptance criteria +Text/description visibility correct; placement stable; reduced motion immediate. +## Verification +Browser accessibility/visual/lifecycle tests. +## Forbidden scope +Collision solver/editor, commit/push/publish. +## Stop conditions +Stop if entrance requires owning host layout. +## Review evidence +Visual and ARIA cases. diff --git a/docs/delivery/tasks/FOLLOW-04.md b/docs/delivery/tasks/FOLLOW-04.md new file mode 100644 index 0000000..b9595d0 --- /dev/null +++ b/docs/delivery/tasks/FOLLOW-04.md @@ -0,0 +1,21 @@ +# FOLLOW-04 — Ordered groups +## Outcome +Small coordinator over handles. **Parent:** B8. **State:** BLOCKED/post-release. +## Prerequisites +CORE-05, AD-INTEGRATE and repeated sequencing use. +## Required reading +Runtime/adapter contracts and existing lifecycle patterns. +## Allowed edits +One group helper/export, focused tests, generated snippets/contracts. +## Exact change +Ordered show, cancellation-aware stop, hide/reset/replay and cleanup. +## Acceptance criteria +No tour/navigation/application progression ownership. +## Verification +Deterministic order/cancellation unit and browser demo. +## Forbidden scope +Timeline DSL, branching, analytics, commit/push/publish. +## Stop conditions +Stop if host-code await loop already meets observed need. +## Review evidence +User need and behavior matrix. diff --git a/docs/delivery/tasks/FOLLOW-05.md b/docs/delivery/tasks/FOLLOW-05.md new file mode 100644 index 0000000..876415e --- /dev/null +++ b/docs/delivery/tasks/FOLLOW-05.md @@ -0,0 +1,21 @@ +# FOLLOW-05 — Per-side padding +## Outcome +CSS-like padding where primitives define padding. **Parent:** C4. **State:** BLOCKED/post-release. +## Prerequisites +Concrete asymmetric placement need. +## Required reading +Primitive geometry, plan generator limitations and tests. +## Allowed edits +Shared normalization, applicable primitives/contracts/tests. +## Exact change +Accept scalar and approved shorthand; decide highlight separately. +## Acceptance criteria +Scalar output unchanged; invalid/negative behavior explicit; schemas represent type or contract is revised first. +## Verification +Normalization table and representative visuals. +## Forbidden scope +Global placement solver, commit/push/publish. +## Stop conditions +Stop if shorthand cannot fit current schema without approved generator work. +## Review evidence +Type/geometry cases. diff --git a/docs/delivery/tasks/FOLLOW-06.md b/docs/delivery/tasks/FOLLOW-06.md new file mode 100644 index 0000000..fab02d2 --- /dev/null +++ b/docs/delivery/tasks/FOLLOW-06.md @@ -0,0 +1,21 @@ +# FOLLOW-06 — Brackets +## Outcome +Side-selectable bracket primitive. **Parent:** C2. **State:** BLOCKED/post-release. +## Prerequisites +FOLLOW-05 and accepted stroked reveal. +## Required reading +Geometry/runtime/adapter/agent contracts. +## Allowed edits +Geometry, primitive, all adapters/catalog/generators/tests. +## Exact change +Add validated top/right/bottom/left selections and reveal. +## Acceptance criteria +One/multiple sides; deterministic; public surfaces complete; unsupported layouts stated. +## Verification +Unit/browser/templates/package/pattern checks. +## Forbidden scope +General shape plugin system, commit/push/publish. +## Stop conditions +Stop on unresolved multiline/RTL interaction. +## Review evidence +Directional visuals and package proof. diff --git a/docs/delivery/tasks/FOLLOW-07.md b/docs/delivery/tasks/FOLLOW-07.md new file mode 100644 index 0000000..e5ec5d2 --- /dev/null +++ b/docs/delivery/tasks/FOLLOW-07.md @@ -0,0 +1,21 @@ +# FOLLOW-07 — Multiline and RTL +## Outcome +Explicit wrapped-text and RTL behavior. **Parent:** C5. **State:** BLOCKED/post-release. +## Prerequisites +Browser-layout investigation and adoption need. +## Required reading +`clientBoxes`, text primitives, RTL browser behavior, plans. +## Allowed edits +Text-layout options/geometry/contracts and focused browser fixtures. +## Exact change +Define single/multiline measurement and reveal direction; state unsupported writing modes. +## Acceptance criteria +LTR/RTL/wrapped/mixed inline cases deterministic and accessible. +## Verification +Real-browser fixtures across supported engines. +## Forbidden scope +Full international typography engine, commit/push/publish. +## Stop conditions +Stop if browser behavior cannot support one documented contract. +## Review evidence +Cross-engine matrix. diff --git a/docs/delivery/tasks/FOLLOW-08.md b/docs/delivery/tasks/FOLLOW-08.md new file mode 100644 index 0000000..b57a581 --- /dev/null +++ b/docs/delivery/tasks/FOLLOW-08.md @@ -0,0 +1,21 @@ +# FOLLOW-08 — Strike-through +## Outcome +Named correction primitive and crossed-off mapping. **Parent:** C3. **State:** BLOCKED/post-release. +## Prerequisites +Accepted line reveal; basic version need not wait for advanced RTL. +## Required reading +Underline/mark geometry, runtime/adapters/agent contracts. +## Allowed edits +Geometry, primitive, all public surfaces and focused tests. +## Exact change +Add `strikeThrough`; document difference from `mark("wrong")`. +## Acceptance criteria +Wrapped behavior defined; reveal works; package/templates complete. +## Verification +Unit/browser/templates/package tests. +## Forbidden scope +Aliases for Rough Notation API, commit/push/publish. +## Stop conditions +Stop if naming conflicts with generated symbol rules. +## Review evidence +Behavior and migration examples. diff --git a/docs/delivery/tasks/FOLLOW-09.md b/docs/delivery/tasks/FOLLOW-09.md new file mode 100644 index 0000000..b04126d --- /dev/null +++ b/docs/delivery/tasks/FOLLOW-09.md @@ -0,0 +1,21 @@ +# FOLLOW-09 — Stroke iterations +## Outcome +Bounded pen passes distinct from boil. **Parent:** C6. **State:** BLOCKED/post-release. +## Prerequisites +Motion pilot evidence. +## Required reading +Path variants, reveal timing, size/DOM measurements. +## Allowed edits +Compatible geometry/render/contracts and focused tests. +## Exact change +Add deterministic bounded iterations and define total timing. +## Acceptance criteria +No conflation with boil; cost measured; invalid values rejected. +## Verification +Geometry, browser and size tests. +## Forbidden scope +Unlimited DOM growth, commit/push/publish. +## Stop conditions +Stop if benefit is aesthetic only with no observed demand. +## Review evidence +Cost and visual comparison. diff --git a/docs/delivery/tasks/FOLLOW-10.md b/docs/delivery/tasks/FOLLOW-10.md new file mode 100644 index 0000000..a677161 --- /dev/null +++ b/docs/delivery/tasks/FOLLOW-10.md @@ -0,0 +1,21 @@ +# FOLLOW-10 — Playwright video recipe +## Outcome +Record reveal order without copied delays. **Parent:** D5. **State:** BLOCKED/post-release. +## Prerequisites +PW-07; FOLLOW-04 only if group helper is used. +## Required reading +Playwright capture and runtime completion contracts. +## Allowed edits +Example/video test and docs. +## Exact change +Drive recording through handle completion or accepted group API. +## Acceptance criteria +No arbitrary Stet sleeps; reduced-motion alternative documented. +## Verification +Event-order assertion and generated recording review. +## Forbidden scope +Video service/codec system, commit/push/publish. +## Stop conditions +Stop if existing Playwright recording cannot reproduce the example. +## Review evidence +Command and artifact. diff --git a/docs/delivery/tasks/FOLLOW-11.md b/docs/delivery/tasks/FOLLOW-11.md new file mode 100644 index 0000000..05cb34c --- /dev/null +++ b/docs/delivery/tasks/FOLLOW-11.md @@ -0,0 +1,21 @@ +# FOLLOW-11 — Evaluate Vite plugin +## Outcome +Plugin exists only if simpler than the proven recipe. **Parent:** E3. **State:** BLOCKED/evidence-gated. +## Prerequisites +SHIP-02 and repeated user setup failures. +## Required reading +Shipping contract, failure evidence, Vite plugin APIs. +## Allowed edits +Bounded plugin entry/tests/docs and package checks if approved. +## Exact change +Automate explicit boundaries; preserve identical output guarantees. +## Acceptance criteria +No JSX/SFC/Angular rewriting; plain recipe remains; measured setup/error improvement. +## Verification +Enabled/disabled/mixed output matrix. +## Forbidden scope +Universal compiler stripping, commit/push/publish. +## Stop conditions +Stop if recipe is equally usable. +## Review evidence +User evidence and output comparison. diff --git a/docs/delivery/tasks/FOLLOW-12.md b/docs/delivery/tasks/FOLLOW-12.md new file mode 100644 index 0000000..88a4aa6 --- /dev/null +++ b/docs/delivery/tasks/FOLLOW-12.md @@ -0,0 +1,21 @@ +# FOLLOW-12 — Storybook starter +## Outcome +Annotations explain a real story without altering controls. **Parent:** G3. **State:** BLOCKED/post-release. +## Prerequisites +AD-INTEGRATE, PW-06 and Storybook user evidence. +## Required reading +Public APIs, iframe contract, current examples. +## Allowed edits +One proposed Storybook example/guide/tests. +## Exact change +Add state, annotation toggle, cleanup and capture inside preview document. +## Acceptance criteria +Published package only; keyboard/behavior preserved; no addon package. +## Verification +Fresh install/build and Playwright behavior check. +## Forbidden scope +Storybook addon before demand, commit/push/publish. +## Stop conditions +Stop if supported frame contract does not cover the recipe. +## Review evidence +Runnable story and user completion. diff --git a/docs/delivery/tasks/FOLLOW-13.md b/docs/delivery/tasks/FOLLOW-13.md new file mode 100644 index 0000000..f03b27c --- /dev/null +++ b/docs/delivery/tasks/FOLLOW-13.md @@ -0,0 +1,21 @@ +# FOLLOW-13 — Production and presentation examples +## Outcome +Demonstrate durable site and host-controlled sequence use. **Parent:** G3. **State:** BLOCKED/post-release. +## Prerequisites +Accepted released motion APIs and relevant demand. +## Required reading +Product direction, public examples and motion/accessibility contracts. +## Allowed edits +Bounded examples/docs/tests. +## Exact change +Build one production annotation and one presentation controlled by host state. +## Acceptance criteria +Reduced motion; real controls; no tour-engine ownership; screenshot alternative stated. +## Verification +Packed build, browser checks and visual review. +## Forbidden scope +Presentation framework/tour controller, commit/push/publish. +## Stop conditions +Stop if example requires unplanned runtime features. +## Review evidence +Runnable demos and checks. diff --git a/docs/delivery/tasks/FOLLOW-14.md b/docs/delivery/tasks/FOLLOW-14.md new file mode 100644 index 0000000..470de40 --- /dev/null +++ b/docs/delivery/tasks/FOLLOW-14.md @@ -0,0 +1,21 @@ +# FOLLOW-14 — Evaluate public update API +## Outcome +Add an API only if internal/adapter updates are insufficient. **Parent:** B6. **State:** BLOCKED/evidence-gated. +## Prerequisites +Repeated consumer requests after CORE-07. +## Required reading +Update contract, request evidence, current internal API/adapters. +## Allowed edits +Decision record first; implementation only after approval. +## Exact change +Specify the smallest proven public update operation. +## Acceptance criteria +Use cases cannot be solved idiomatically through existing adapters/handles; compatibility defined. +## Verification +API review and consumer prototype. +## Forbidden scope +Mutable property clone/future-proof config system, commit/push/publish. +## Stop conditions +Stop if no repeated demand exists. +## Review evidence +Requests and prototype comparison. diff --git a/docs/delivery/tasks/HANDOFF-01.md b/docs/delivery/tasks/HANDOFF-01.md new file mode 100644 index 0000000..5955ea1 --- /dev/null +++ b/docs/delivery/tasks/HANDOFF-01.md @@ -0,0 +1,21 @@ +# HANDOFF-01 — Publish a complete handoff example +## Outcome +Another developer can reproduce source-authored and injected delivery. **Parent:** D7. **State:** BLOCKED. +## Prerequisites +PW-07, PW-08 and AGENT-03 accepted. +## Required reading +Public APIs, examples, agent plans and verification guidance. +## Allowed edits +One bounded example, its docs/tests and retained evidence. +## Exact change +Include preview/state/version context, plan where applicable, screenshot, explanation, checks and uncertainty. +## Acceptance criteria +Uses released APIs; source and injected variants honest; no QA/agent-runtime claim. +## Verification +Independent clean reproduction from documented commands. +## Forbidden scope +Hosted artifact system, fabricated checks, commit/push/publish. +## Stop conditions +Stop if reproduction depends on checkout-only behavior. +## Review evidence +Independent rerun and artifact links. diff --git a/docs/delivery/tasks/MEASURE-01.md b/docs/delivery/tasks/MEASURE-01.md new file mode 100644 index 0000000..82df979 --- /dev/null +++ b/docs/delivery/tasks/MEASURE-01.md @@ -0,0 +1,24 @@ +# MEASURE-01 — Separate payload measurements + +## Outcome +Reproducible minimal, full-core, CSS, and optional-integration measurements. +## Parent +A5. **State: BLOCKED.** +## Prerequisites +Accepted BASE-01. +## Required reading +`scripts-size.mjs`, `scripts/agent/check-package.mjs`, package exports and side effects. +## Allowed edits +Measurement scripts/tests and measurement docs. +## Exact change +Add separate bundle cases without changing current limits. +## Acceptance criteria +Core excludes agent/Node/Playwright code; method is reproducible; tree-shaking is measured. +## Verification +`npm run size`, `npm run test:package`. +## Forbidden scope +Budget increases, runtime optimization/refactor, dependencies, commit/push/publish. +## Stop conditions +Stop if a bundler change would make results incomparable without lead approval. +## Review evidence +Commands, inputs, outputs, current limits. diff --git a/docs/delivery/tasks/PW-01.md b/docs/delivery/tasks/PW-01.md new file mode 100644 index 0000000..abb6adf --- /dev/null +++ b/docs/delivery/tasks/PW-01.md @@ -0,0 +1,23 @@ +# PW-01 — Prove renderer and CSS loading +## Outcome +Evidence selects a loader; no production helper yet. **Parent:** D1/D2. **State:** BLOCKED. +## Prerequisites +CONTRACT-03 accepted. +## Required reading +Playwright contract, package build/exports, existing Playwright configs and source-authored runtime. +## Allowed edits +Isolated proposed Playwright investigation fixture/config and evidence. +## Exact change +Test local payload and stylesheet approaches in Chromium and Firefox, restrictive +CSP, same-origin frame, rollback, and coexistence. Record optional WebKit results +only when run with `STET_WEBKIT=1`. +## Acceptance criteria +No silent bypass/global collision; CSS does not restyle foreign marks; failure cleans partial resources; claims are measured. +## Verification +Dedicated investigation suite; record each browser/CSP result. +## Forbidden scope +Public export, CDN, consumer esbuild, production helper, commit/push/publish. +## Stop conditions +Stop if no approach satisfies ownership/CSP without changing the contract. +## Review evidence +Compared approaches and accepted mechanism. diff --git a/docs/delivery/tasks/PW-02.md b/docs/delivery/tasks/PW-02.md new file mode 100644 index 0000000..31ae41b --- /dev/null +++ b/docs/delivery/tasks/PW-02.md @@ -0,0 +1,21 @@ +# PW-02 — Prove payload and navigation lifetime +## Outcome +Packed local payload works without consumer build dependencies. **Parent:** D1/D2/D3. **State:** BLOCKED. +## Prerequisites +PW-01 and CORE-05 accepted. +## Required reading +PW-01 evidence, package checks, build pipeline, runtime cancellation contract. +## Allowed edits +Packed-consumer fixture, proposed payload build experiment, evidence. +## Exact change +Load from installed tarball; navigate during reveal; test cleanup/foreign ownership and core graph isolation. +## Acceptance criteria +No CDN/esbuild requirement; pending calls settle; source-authored marks survive; no Node/Playwright core leak. +## Verification +Packed consumer plus module-graph inspection. +## Forbidden scope +Public API/export before design acceptance, swallowed browser errors, commit/push/publish. +## Stop conditions +Stop if payload cannot be discovered portably from the installed package. +## Review evidence +Package contents, navigation outcomes, graph evidence. diff --git a/docs/delivery/tasks/PW-03.md b/docs/delivery/tasks/PW-03.md new file mode 100644 index 0000000..b547391 --- /dev/null +++ b/docs/delivery/tasks/PW-03.md @@ -0,0 +1,21 @@ +# PW-03 — Build the optional Playwright entry +## Outcome +`@funsaized/stet/playwright` and its prebuilt local payload resolve from a package. **Parent:** D2. **State:** BLOCKED. +## Prerequisites +PW-02 accepted. +## Required reading +Accepted PW contracts/evidence, `package.json`, TypeScript/build and package checks. +## Allowed edits +Proposed helper/bridge/build sources, package/build config, focused package tests. +## Exact change +Implement only the accepted loading/package design. +## Acceptance criteria +JS/types/assets packed; caller supplies Playwright; root/core imports stay browser-only; build reproducible. +## Verification +`npm run check`, `npm run test:package`, fresh packed consumer, size/graph checks. +## Forbidden scope +Runtime redesign, CDN, dependency leakage, unrelated exports, commit/push/publish. +## Stop conditions +Stop if package checker assumptions require broader release redesign. +## Review evidence +Tarball manifest, import smoke test, graph. diff --git a/docs/delivery/tasks/PW-04.md b/docs/delivery/tasks/PW-04.md new file mode 100644 index 0000000..85095c9 --- /dev/null +++ b/docs/delivery/tasks/PW-04.md @@ -0,0 +1,21 @@ +# PW-04 — Add sessions and locator attachment +## Outcome +Owned sessions attach supported annotations to one live target. **Parent:** D2/D3. **State:** BLOCKED. +## Prerequisites +PW-03 accepted. +## Required reading +Playwright contract/API, helper source, runtime primitive signatures. +## Allowed edits +Helper/bridge and dedicated integration tests. +## Exact change +Resolve exact-one locators, validate documents, own handles/resources, and roll back partial failure. +## Acceptance criteria +Missing/ambiguous/detached/wrong-document differ; races do not silently substitute targets; sessions coexist with source marks. +## Verification +Dedicated page/frame/mixed-ownership integration tests. +## Forbidden scope +Auto rediscovery/reinjection, cross-document arrows, global overlay deletion, commit/push/publish. +## Stop conditions +Stop if target identity cannot be maintained with the accepted API. +## Review evidence +Positive/negative locator matrix. diff --git a/docs/delivery/tasks/PW-05.md b/docs/delivery/tasks/PW-05.md new file mode 100644 index 0000000..44e63f3 --- /dev/null +++ b/docs/delivery/tasks/PW-05.md @@ -0,0 +1,21 @@ +# PW-05 — Add remote handles and cleanup +## Outcome +Remote lifecycle mirrors runtime results and survives navigation safely. **Parent:** D3. **State:** BLOCKED. +## Prerequisites +PW-04 and CORE-05 accepted. +## Required reading +Runtime/Playwright contracts and session implementation. +## Allowed edits +Helper/bridge and lifecycle tests. +## Exact change +Expose async show/hide/replay/destroy, navigation cancellation, and idempotent session cleanup. +## Acceptance criteria +No hung caller; only owned resources removed; unexpected browser errors visible; destroyed-session behavior exact. +## Verification +Interruption, navigation, multiple-session, and foreign-ownership tests. +## Forbidden scope +Error swallowing, auto navigation recovery, global cleanup, commit/push/publish. +## Stop conditions +Stop after two failed fixes for the same race and retain reproduction. +## Review evidence +Lifecycle matrix and listener/resource checks. diff --git a/docs/delivery/tasks/PW-06.md b/docs/delivery/tasks/PW-06.md new file mode 100644 index 0000000..769fccd --- /dev/null +++ b/docs/delivery/tasks/PW-06.md @@ -0,0 +1,22 @@ +# PW-06 — Freeze frame and CSP support +## Outcome +Investigation behavior becomes permanent tests and honest documentation. **Parent:** D1/D3. **State:** BLOCKED. +## Prerequisites +PW-05 accepted. +## Required reading +PW-01/PW-02 evidence, helper, compatibility docs. +## Allowed edits +Integration fixtures/tests and Playwright compatibility documentation. +## Exact change +Cover Page and same-origin Frame in Chromium and Firefox, restrictive CSP, and +explicit unsupported contexts. WebKit is not claimed unless made a release gate. +## Acceptance criteria +Cross-document arrows rejected; no implicit bypass/reinjection; errors actionable. +## Verification +Dedicated Playwright integration matrix in configured engines. +## Forbidden scope +Cross-origin support, broad browser claims, commit/push/publish. +## Stop conditions +Stop if a documented claim lacks a reproducible fixture. +## Review evidence +Engine/context matrix. diff --git a/docs/delivery/tasks/PW-07.md b/docs/delivery/tasks/PW-07.md new file mode 100644 index 0000000..8fc1766 --- /dev/null +++ b/docs/delivery/tasks/PW-07.md @@ -0,0 +1,21 @@ +# PW-07 — Add deterministic screenshot recipe +## Outcome +Source and injected annotations produce reproducible screenshots. **Parent:** D4. **State:** BLOCKED. +## Prerequisites +PW-05 and CORE-06 accepted. +## Required reading +Playwright/runtime contracts, existing capture fixtures and examples. +## Allowed edits +Proposed `examples/playwright/`, focused tests and integration guide. +## Exact change +Use public APIs with explicit state/viewport/seed/font/reveal/boil settling. +## Acceptance criteria +No arbitrary animation sleep; repeat capture stable on pinned CI; plain context accompanies image. +## Verification +Repeat screenshot test and example build. +## Forbidden scope +New export format/hosting, snapshot acceptance without visual review, commit/push/publish. +## Stop conditions +Stop if stability requires an undocumented internal hook. +## Review evidence +Commands and compared artifacts. diff --git a/docs/delivery/tasks/PW-08.md b/docs/delivery/tasks/PW-08.md new file mode 100644 index 0000000..3202783 --- /dev/null +++ b/docs/delivery/tasks/PW-08.md @@ -0,0 +1,21 @@ +# PW-08 — Add independent behavior verification +## Outcome +An annotated handoff checks native behavior separately. **Parent:** D6. **State:** BLOCKED. +## Prerequisites +PW-05 accepted. +## Required reading +Existing trial verification scripts, accessibility/lifecycle contracts. +## Allowed edits +Playwright example verification and deliberately broken fixture/test. +## Exact change +Check identity, focus, typing, submit, layout, descriptions, pointer behavior and cleanup. +## Acceptance criteria +Broken fixture is detected; annotation presence is not a pass condition; cleanup runs after failure. +## Verification +Runnable packed-consumer Playwright test. +## Forbidden scope +QA product claims, visual-only assertion, commit/push/publish. +## Stop conditions +Stop if app behavior cannot be asserted independently of Stet. +## Review evidence +Passing and deliberate-failure outcomes. diff --git a/docs/delivery/tasks/RELEASE-01.md b/docs/delivery/tasks/RELEASE-01.md new file mode 100644 index 0000000..1f6aeda --- /dev/null +++ b/docs/delivery/tasks/RELEASE-01.md @@ -0,0 +1,26 @@ +# RELEASE-01 — Run the full release gate +## Outcome +All required behavior is verified as an integrated package. **Parent:** H2. **State:** BLOCKED. +## Prerequisites +CONTRACT-05, FIX-01/02, MEASURE-01, CORE-01 through CORE-08, all AD tasks, +BOX-01/02, PW-01 through PW-08, SHIP-01 through SHIP-03, AGENT-01 through +AGENT-03, HANDOFF-01, WEB-01/02, and DOCS-01 accepted. RELEASE-01 and RELEASE-02 +are not their own prerequisites. +## Required reading +All contracts, task evidence, release workflow/docs and candidate diff. +## Allowed edits +Only fixes dispatched as separate bounded tasks and release evidence. +## Exact change +Run unit/type/generation/template/package/framework/SSR/browser/Playwright/exclusion/size/manual visual matrix. +## Acceptance criteria +No skipped check called pass; package/docs agree; initial failures remain visible; known limits recorded. +## Verification +All commands listed in `README.md`, including +`npm --prefix website run check` and `npm --prefix website test`, plus manual +animation review. +## Forbidden scope +Publishing, blanket snapshots, hidden budget changes, opportunistic fixes, commit/push. +## Stop conditions +Any required check fails or is unavailable. +## Review evidence +Exact revision, commands, artifacts and independent decision. diff --git a/docs/delivery/tasks/RELEASE-02.md b/docs/delivery/tasks/RELEASE-02.md new file mode 100644 index 0000000..7ae5111 --- /dev/null +++ b/docs/delivery/tasks/RELEASE-02.md @@ -0,0 +1,21 @@ +# RELEASE-02 — Freeze public proof to the artifact +## Outcome +Website/examples consume the actual candidate artifact. **Parent:** A1/G1/G2. **State:** BLOCKED. +## Prerequisites +RELEASE-01 accepted candidate artifact. +## Required reading +Website/package release configs and release evidence. +## Allowed edits +Stable website/example dependency configuration and corresponding checks. +## Exact change +Remove checkout-only stable aliases and bind version/capabilities/snippets to the artifact. +## Acceptance criteria +Website result reproduces in fresh consumer; agent capabilities and displayed version agree. +## Verification +Website build/test and packed consumer. +## Forbidden scope +Publishing, feature changes, commit/push. +## Stop conditions +Stop if candidate artifact lacks any advertised public proof. +## Review evidence +Artifact checksum/version and reproduction. diff --git a/docs/delivery/tasks/SHIP-01.md b/docs/delivery/tasks/SHIP-01.md new file mode 100644 index 0000000..742a54d --- /dev/null +++ b/docs/delivery/tasks/SHIP-01.md @@ -0,0 +1,22 @@ +# SHIP-01 — Build explicit exclusion fixtures +## Outcome +Production, preview, local-only, disabled, and mixed Vite modes use a review-only module boundary. **Parent:** E2. **State:** BLOCKED. +## Prerequisites +CONTRACT-04 accepted. +## Required reading +Shipping contract, Vite/package side-effect behavior, packed-consumer patterns. +## Allowed edits +Proposed build-exclusion consumer fixture and its configuration/tests. +## Exact change +Implement all contracted modes with a normal compile-time boundary and no source rewriting. +## Acceptance criteria +Production and mixed retain intentional marks; preview/local behave as documented; +disabled app works; no privacy claim from no-op rendering. +## Verification +Fresh production builds and runtime smoke tests. +## Forbidden scope +AST stripping, Vite plugin, product runtime edits, commit/push/publish. +## Stop conditions +Stop if packed package behavior differs from the contract. +## Review evidence +Mode outputs and runtime screenshots/status. diff --git a/docs/delivery/tasks/SHIP-02.md b/docs/delivery/tasks/SHIP-02.md new file mode 100644 index 0000000..49ebf8a --- /dev/null +++ b/docs/delivery/tasks/SHIP-02.md @@ -0,0 +1,21 @@ +# SHIP-02 — Inspect excluded output +## Outcome +Output scans falsify runtime/content exclusion claims. **Parent:** E4. **State:** BLOCKED. +## Prerequisites +SHIP-01 accepted. +## Required reading +Shipping contract, fixture outputs, manifest/source-map formats. +## Allowed edits +Output-inspection script and positive/negative fixtures. +## Exact change +Scan JS, CSS, assets, reachable chunks and published maps for review sentinels and unused Stet. +## Acceptance criteria +Mixed mode keeps only intended content; failure names offending asset; stale output cannot mask result. +## Verification +Passing fixture plus deliberately failing sentinel case. +## Forbidden scope +Disabling source maps globally without contract, brittle filename assumptions, commit/push/publish. +## Stop conditions +Stop if emitted format cannot support a reliable scan without narrowing the guarantee. +## Review evidence +Artifact inventory and scan results. diff --git a/docs/delivery/tasks/SHIP-03.md b/docs/delivery/tasks/SHIP-03.md new file mode 100644 index 0000000..8f9eb0e --- /dev/null +++ b/docs/delivery/tasks/SHIP-03.md @@ -0,0 +1,21 @@ +# SHIP-03 — Publish shipping and agent rules +## Outcome +Users and agents implement only tested shipping guarantees. **Parent:** E1/F4. **State:** BLOCKED. +## Prerequisites +SHIP-02 accepted. +## Required reading +Shipping contract/evidence, agent skill references and docs. +## Allowed edits +Shipping documentation, relevant skill sources/tests. +## Exact change +Document modes, boundary placement, source maps and required output verification. +## Acceptance criteria +No environment-flag-only or universal stripping claim; every guarantee links to tested configuration. +## Verification +Skill tests, docs/example consistency, output command rerun. +## Forbidden scope +Plugin implementation, runtime changes, commit/push/publish. +## Stop conditions +Stop if prose exceeds demonstrated fixture behavior. +## Review evidence +Claim-to-test map. diff --git a/docs/delivery/tasks/WEB-01.md b/docs/delivery/tasks/WEB-01.md new file mode 100644 index 0000000..3a5a0f7 --- /dev/null +++ b/docs/delivery/tasks/WEB-01.md @@ -0,0 +1,23 @@ +# WEB-01 — Make the homepage outcome-led +## Outcome +The first proof is a state-dependent live explanation. **Parent:** G1. **State:** BLOCKED. +## Prerequisites +FIX-01 and CORE-06 accepted. Use the accepted animated circle/underline example; +do not wait for optional examples. +## Required reading +Product direction, website home/scenario/styles, website tests. +## Allowed edits +Homepage and directly relevant components/styles/tests. +## Exact change +Lead with live outcome; make gallery secondary; show equal developer/agent paths and optional animation. +## Acceptance criteria +No unreleased snippet or unsupported claim; category/user/value/next action clear; mobile/keyboard preserved. +## Verification +`npm --prefix website run check`, `npm --prefix website test`, and human +comprehension review. +## Forbidden scope +Runtime work, broad redesign, analytics beyond approved task, commit/push/publish. +## Stop conditions +Stop if stable example requires unreleased package behavior. +## Review evidence +Responsive snapshots, tests, five-second feedback. diff --git a/docs/delivery/tasks/WEB-02.md b/docs/delivery/tasks/WEB-02.md new file mode 100644 index 0000000..fccf23d --- /dev/null +++ b/docs/delivery/tasks/WEB-02.md @@ -0,0 +1,22 @@ +# WEB-02 — Add equal first-success paths +## Outcome +“Write it yourself” and “Ask your agent” reach the same released result. **Parent:** G2. **State:** BLOCKED. +## Prerequisites +FIX-02, AGENT-02 and HANDOFF-01 accepted. +## Required reading +Quickstart/navigation/copy components and approved examples. +## Allowed edits +Website/docs first-success paths and direct tests. +## Exact change +Add parallel starts covering CSS, cleanup, motion choice, and source-versus-injection. +## Acceptance criteria +Mobile CTA available; all commands/snippets release-correct; both paths converge visibly. +## Verification +Fresh consumer, `npm --prefix website run check`, and +`npm --prefix website test` for mobile/keyboard/navigation. +## Forbidden scope +Separate agent product/funnel, unsupported claims, commit/push/publish. +## Stop conditions +Stop if either path cannot be reproduced from package assets. +## Review evidence +Path completion and command results. diff --git a/docs/product-direction.md b/docs/product-direction.md new file mode 100644 index 0000000..c594d10 --- /dev/null +++ b/docs/product-direction.md @@ -0,0 +1,1727 @@ +# Stet product direction + +Status: proposed product and adoption plan +Last updated: 2026-09-12 + +This document records the product direction agreed after reviewing Stet's runtime, +framework adapters, CLI, Agent Skills, evaluation evidence, examples, website, +package metadata, and adjacent markets. It is both a strategy and an execution +plan. It does not describe every item below as an existing capability. + +Terms used throughout: + +- **Current** means implemented in the published or checked-out project, with any + version difference called out explicitly. +- **First direction release** means the first release that delivers the new motion + and Playwright direction. Its version has not been chosen. +- **Proposed** means the behavior needs design and implementation. +- **Deferred** means it should not be built without new evidence. + +Approved implementation behavior and executable task packets live in the +[delivery plan](delivery/README.md). Those contracts supersede unresolved API +questions in earlier planning notes. + +## 1. Executive direction + +> **Stet is a framework-aware library for expressive, optionally animated annotations on +> live interfaces—authored by developers or coding agents, published in websites, +> demonstrations, and presentations, or delivered as inspectable browser +> artifacts.** + +Stet should remain one product with two equal authoring paths: + +1. A developer uses the typed API and framework adapters directly. +2. A coding agent uses the same API through project-local skills, structured + plans, canonical snippets, and external verification tools. + +The output is the same in both cases: annotations attached to working interface +elements. The author and lifetime vary: + +| Dimension | Supported direction | +| --- | --- | +| Author | Developer or coding agent | +| Destination | Live site, interactive documentation, demonstration, presentation, or review artifact | +| Lifetime | Permanent, preview-only, or development-only | +| Delivery | Source-authored or temporarily injected through Playwright | +| Verification | Existing application tests and browser tools, especially Playwright | + +The initial adoption wedge remains deliberately narrower than the product: + +> **Frontend developers maintaining interactive examples and component +> documentation who need to explain behavior without turning the example into a +> screenshot.** + +That wedge offers an identifiable installer, an existing publishing environment, +recurring examples, and a natural public distribution loop. It does not restrict +the runtime to documentation. + +Agentic delivery remains first-class. What Stet must not claim is that it runs an +agent, performs QA, applies plans automatically, or supplies collaboration +infrastructure. Stet supplies an agent-ready annotation contract and delivery +workflow; the coding agent and the user's existing tools perform the work. + +## 2. Product thesis + +### 2.1 The job + +Static screenshots are often sufficient for a one-off explanation. Stet earns its +place when the explanation depends on a reader being able to: + +- Click, type, focus, submit, or change state. +- See a relationship between controls and resulting behavior. +- Follow an explanation through a live demonstration or presentation. +- Inspect what an agent changed in the interface where it changed. +- Capture a deterministic annotated artifact after reaching a meaningful state. + +The hand-drawn style is an attention mechanism and visual identity, not the whole +value proposition. Lifecycle correctness, finite motion, deterministic capture, +and authoring ergonomics turn the visual effect into a usable library. + +### 2.2 Why Stet can exist + +Stet can occupy the space between four established categories: + +- **Rough Notation and custom SVG/CSS:** expressive marks, but limited shared + lifecycle, framework, and agent contracts. +- **Tour libraries:** sequence and control onboarding, but own more interaction and + application behavior than Stet should. +- **Feedback products:** capture, persist, assign, and resolve comments, but require + a service and solve a human-to-work-item workflow. +- **Browser-agent tools:** inspect, operate, and capture interfaces, but do not + provide a source-owned vocabulary for explaining them. + +Stet's intended combination is: + +- A small, local, source-owned runtime. +- Expressive primitives, including relationships and notes. +- Finite reveal animation plus optional ambient motion. +- Framework-aware lifecycle behavior. +- Deterministic rendering and honest accessibility boundaries. +- Equal developer and agent authoring paths. +- First-class Playwright verification, capture, and temporary injection. + +No individual feature is defensible by itself. Adoption must come from the whole +workflow being easier and more reliable than assembling it ad hoc. + +### 2.3 Product boundaries + +Stet is not: + +- A guided-tour controller. +- A product-adoption analytics platform. +- A drawing canvas or visual editor. +- A feedback database or issue tracker. +- A source-apply engine. +- A browser automation framework. +- A coding agent or orchestration service. +- A visual-regression or QA verdict engine. + +Host applications continue to own navigation, application state, focus policy, +progression, persistence, authorization, and business behavior. Playwright or +another browser tool continues to own browser operation and assertions. + +## 3. Decision register + +### 3.1 Agreed + +- Preserve the existing named primitive API rather than replacing it with a + universal factory. +- Pursue substantial Rough Notation feature coverage while modernizing ergonomics. +- Make animation a first-class product capability. +- Keep reveal animation opt-in; existing static attachment remains the default. +- Supplying reveal-animation options opts an annotation into one reveal on attach; + `visible: false` defers that reveal until the first `show()`. +- Keep `hide()` immediate. Stet does not animate disappearance in the first + direction release. +- Use flat `visible`, `animate`, `animationDuration`, and `animationDelay` options. +- Return awaitable `finished` or `cancelled` results from show/replay operations. +- Give every existing framework adapter declarative visibility and imperative + handle access. +- Keep finite reveal animation separate from ambient `boil` motion. +- Keep developer and agent authoring equally supported. +- Include source-free Playwright annotation injection in the first direction + release. +- Treat intentional production annotations as supported, not as development code + that must always be stripped. +- Support explicit production, preview-only, local-only, and mixed shipping modes. +- Preserve a small, local, backend-free core and zero required runtime + dependencies. +- Use repeat adoption—not feature count, stars, or unexplained downloads—as the + main product signal. + +### 3.2 Approved implementation direction + +- Add explicit `show`, `hide`, and `replay` behavior to annotation handles. +- Make finite animation completion awaitable and cancellation-distinguishable so + consumers can sequence without manually reproducing durations. +- Add a small ordered-group helper once the single-handle contract is stable. +- Add a typed update operation only where remounting causes concrete animation or + lifecycle problems. +- Put temporary review annotations behind explicit source-module boundaries. +- Expose Playwright support through an optional package entry point if a recipe + proves insufficient. +- Offer a bounded Vite integration for explicit boundaries only if it materially + improves the tested workflow. + +### 3.3 Evidence-gated questions + +- Primitive-specific animation treatments and defaults. +- The proven renderer/stylesheet loading mechanism and detailed CSP compatibility + for browser injection. +- The revised core and optional-integration size budgets. +- Whether a Vite plugin adds enough value over an explicit conditional module. + +### 3.4 Deferred until evidence exists + +- Hosted comments, accounts, organizations, and permissions. +- A Stet-owned MCP server. +- Automatic plan application or arbitrary AST rewriting. +- A browser extension or DevTools panel. +- A drag-and-drop annotation editor. +- Automatic diff-to-explanation generation. +- Cross-page tour orchestration and analytics. +- A universal collision-free placement engine. +- Custom primitive plugins. +- Additional framework adapters. + +## 4. Current product truth + +### 4.1 Runtime + +The current runtime exposes six primitives: + +| Primitive | Current purpose | +| --- | --- | +| `circle` | Emphasize an element | +| `underline` | Emphasize text, including wrapped lines | +| `highlight` | Apply a marker-like wash behind text | +| `arrow` | Explain a relationship between two elements, optionally with a label | +| `sticky` | Attach a textual note near an element | +| `mark` | Apply a right/wrong proofreader mark | + +Every primitive returns a handle containing `resketch`, `refresh`, and `destroy`. +Annotations attach and render immediately. There is no public show/hide state, +finite entrance animation, replay, completion signal, or ordered grouping. +Internally, the mount layer already uses the overlay's `hidden` state for +intersection and offscreen culling. Public visibility must be a separate state so +an observer cannot undo an explicit `hide()`. + +The shared mount layer currently handles: + +- Resize observation. +- Intersection visibility. +- Nested and document scrolling. +- Font-loading refresh. +- Live reduced-motion changes. +- Deterministic seeds and resketching. +- Accessible descriptions. +- Idempotent cleanup. + +`boil` pre-generates path variants and switches between them with CSS. It is an +ambient loop, not a draw-on animation. + +### 4.2 Framework adapters + +React components, Vue directives, Svelte actions, Angular directives, and the +vanilla API all route into the same runtime. The adapters are deliberately thin. +They generally destroy and recreate a handle when a target or snapshotted option +changes. + +This is acceptable for static annotations but can cause accidental reveal replays +once animation exists. Adapter behavior must therefore be included in animation +design rather than patched afterward. + +### 4.3 Agent tooling + +The CLI currently supports: + +- Installed-version and project inspection. +- Capability and annotation-plan schemas. +- Plan validation. +- Canonical primitive and lifecycle snippets. +- Project-local Agent Skill initialization and updates. + +The generated plan describes intent, primitive choices, targets, source evidence, +and options. Validation establishes structure and selected semantic constraints. +It does not establish that a target exists, copy is truthful, placement is usable, +or the application works. + +The external coding agent edits source. Existing compilers, tests, Playwright, and +human review verify the result. That boundary remains intentional. + +### 4.4 Existing Playwright evidence + +The repository already uses Playwright to exercise: + +- Browser rendering and accessibility behavior. +- Scroll and live reduced-motion behavior. +- Framework lifecycle and observer cleanup. +- SSR/hydration boundaries. +- Demonstration fixtures. +- Retained agent-task artifacts and application behavior. + +This is internal verification, not yet a supported consumer integration. The +first-release work should extract the smallest reusable patterns rather than +exporting the entire test harness. + +### 4.5 Known limitations + +- Arrow paths do not avoid intervening UI. +- Sticky notes and labels do not solve global collisions. +- Top-layer dialogs, cross-document elements, transformed or zoomed roots, and + some clipped containers are unsupported or constrained. Shadow-root targeting + has not been established by the current test matrix. +- Accessibility behavior is tested but not a screen-reader certification. +- Browser injection does not yet exist as a supported public API. +- The public website can currently demonstrate checkout-only placement options + while advertising published `0.1.0`; this must be corrected before feature work + is promoted. + +### 4.6 Size and packaging + +The checked-in build measured on 2026-09-12 at approximately 6.83 KiB gzip for the +core JavaScript and 1.11 KiB gzip for CSS. The core budget is currently 7 KiB, so +the proposed functionality cannot be promised within that ceiling without +measurement. + +The project should preserve tree-shaking and report at least: + +- Minimal single-primitive consumer. +- Full core public surface. +- CSS. +- Each optional integration, especially Playwright and build tooling. + +The installed npm package is larger because it includes agent tooling, +documentation, examples, and evidence assets. Browser payload and npm unpacked +size must not be conflated. + +The browser runtime is client-side ESM. The CLI currently documents Node 20 or +newer, while the website package declares Node 22.12 or newer and the root package +does not declare an `engines` field. A6 must make these separate build-time and +consumer requirements explicit rather than presenting one ambiguous Node floor. + +## 5. Rough Notation capability direction + +Rough Notation is a useful baseline because it established familiar annotation +behavior. Stet should cover the useful jobs without claiming drop-in API +compatibility. + +| Capability | Current Stet | Direction | +| --- | --- | --- | +| Circle | Present | Preserve and animate | +| Underline | Present | Preserve and animate | +| Highlight | Present | Preserve; add appropriate marker-sweep reveal | +| Box | Missing | Add | +| Bracket on selected sides | Missing | Add | +| Strike-through | Missing | Add | +| Crossed-off | Similar to `mark(element, "wrong")` | Document equivalence and any difference | +| Draw-on animation | Missing | Add as opt-in finite reveal | +| Animation duration | Missing | Add | +| Show/hide state | Missing | Add | +| `isShowing()` | Missing | Expose only if it adds value beyond the chosen visibility contract | +| Explicit replay | Missing | Add rather than requiring hide/show knowledge | +| Ordered groups | Missing | Add after handle completion is stable | +| Group hide | Missing | Include if an ordered-group helper ships | +| Iterations | Missing | Add after reveal model is proven | +| Per-side padding | Scalar only | Add CSS-like shorthand | +| Multiline control | Automatic for selected primitives | Specify and expose only where useful | +| RTL direction | Not explicit | Add deliberate direction behavior | +| Mutable config properties | Remount in adapters | Prefer typed update only where justified | +| Animate by default | No finite reveal | Intentionally differ: static by default, opt-in reveal | + +### 5.1 API principles + +- Keep named functions such as `circle`, `arrow`, and `sticky`. +- Add named `box`, `bracket`, and `strikeThrough` functions. +- Keep `destroy`; do not add `remove` as a synonym. +- Keep `refresh` separate from `resketch` and animation replay. +- Keep Stet's existing `stroke`, `width`, and `description` vocabulary unless a + migration reason is stronger than compatibility cost. +- Prefer typed methods over mutable public properties. +- Provide a migration guide instead of permanently supporting two APIs. +- Preserve applicable attribution if source or algorithms are ported rather than + independently implemented. + +### 5.2 Illustrative candidate handle—not the specification + +```ts +const emphasis = circle(target, { + visible: false, + animate: true, + animationDuration: 600, + boil: 0.15, +}); + +await emphasis.show(); +await emphasis.replay(); +emphasis.hide(); +emphasis.refresh(); +emphasis.resketch(); +emphasis.destroy(); +``` + +This example illustrates the approved public direction. Exact state transitions +and adapter syntax are defined by the delivery contracts before implementation. + +## 6. Motion model + +### 6.1 Three independent concerns + +**Appearance** controls the settled drawing: + +- Seed and roughness. +- Stroke, fill, and width. +- Padding and iterations. + +**Reveal** controls finite entrance: + +- Initial visibility. +- Enabled state. +- Duration and delay. +- Direction where meaningful. +- Completion and cancellation. + +**Ambient motion** controls behavior after reveal: + +- Existing boil. +- Hover resketching. +- Capture-time pause or deterministic settling where required. + +Changing appearance must not implicitly mean replaying reveal. Ambient motion must +not prevent a finite reveal from completing. + +### 6.2 Primitive-specific behavior + +A single path animation is not appropriate for every primitive: + +- Circle, underline, box, bracket, strike-through, and check/wrong strokes can use + path-length-based drawing where geometry permits. +- Highlight needs a wash or mask treatment; tracing its filled outline is not a + convincing highlighter animation. +- Arrow shaft and head need intentional ordering. +- Sticky paper and text should enter coherently rather than animating as an + arbitrary SVG outline. + +Prototype one stroked primitive, highlight, arrow, and sticky before generalizing +the animation implementation. + +### 6.3 Runtime invariants + +- Animation remains opt-in. +- `refresh()` remeasures without replaying. +- `resketch()` changes geometry without replaying unless explicitly requested. +- Scroll, resize, observer, font, and theme refreshes do not restart reveal. +- Explicit hiding is independent of intersection visibility. +- A hidden annotation has defined accessible-description behavior. +- Reduced motion reaches the final visible state without waiting for a duration. +- Hover resketch never creates a replay loop. +- `hide()` or `destroy()` settles pending completion and removes listeners. +- Repeated and overlapping animation commands have deterministic behavior. +- The original element retains layout, identity, focus, and event behavior. +- An ordered group owns annotation timing only, not application progression. + +## 7. Playwright integration + +### 7.1 Integration thesis + +> **Playwright reaches and checks the state. Stet explains it. Playwright captures +> the result.** + +An annotation never proves that the underlying behavior passed. Verification +output must name the assertions and tools that actually ran. + +### 7.2 Source-authored workflow + +For an application that already contains Stet annotations, a supported Playwright +recipe should: + +1. Navigate to the relevant route. +2. Reach a meaningful application state. +3. Show or replay the intended annotations. +4. Await finite animation completion. +5. Verify native control behavior independently. +6. Settle or pause ambient motion as appropriate. +7. Capture a screenshot or recording. + +Reusable checks should cover control identity, focus, typing, form behavior, +`aria-describedby` integrity, layout preservation, and teardown. + +### 7.3 Browser-injected workflow + +The first direction release must also support temporary annotations without +application source edits: + +1. Playwright navigates and reaches the desired state. +2. The test resolves targets using Playwright locators. +3. An optional Stet helper loads the renderer into that document. +4. Stet attaches annotations to the resolved elements. +5. The caller awaits reveal and captures the artifact. +6. The helper removes only the annotations it owns. + +This workflow is appropriate for temporary review, demonstrations, agent handoffs, +and captured release artifacts. Source-authored annotations remain the better fit +when the explanation belongs in the published application. + +### 7.4 Initial support contract to define + +- Supported Playwright browser engines. +- Main-document and same-origin frame behavior. +- Explicit handling of cross-origin frames and cross-document arrows. +- Target replacement and navigation behavior. +- Missing and multiple target errors. +- CSP restrictions and failure messages. +- Isolation from source-authored Stet annotations. +- Cleanup after normal completion, test failure, and page navigation. +- Loading of JavaScript and CSS without introducing a core Playwright dependency. + +The likely packaging direction is an optional `@funsaized/stet/playwright` entry +point, but the API should first be proven as a repository recipe. Playwright must +not enter the browser core's dependency graph. + +### 7.5 Artifacts + +The minimum portable handoff consists of existing formats: + +- Screenshot or video. +- Preview URL or route. +- Application state/setup description. +- Commit and Stet version where available. +- Plain-language explanation. +- Checks actually performed and remaining uncertainty. + +Do not introduce a hosted artifact format before users repeatedly share these +parts and expose a concrete automation gap. + +## 8. Shipping and build integration + +### 8.1 Supported shipping modes + +| Mode | Expected result | +| --- | --- | +| Production | Intentional annotations and runtime ship | +| Preview-only | Review annotations ship in preview builds and are absent from customer production | +| Local-only | Annotations exist only in development | +| Mixed | Intentional production annotations remain while temporary review annotations are removed | + +### 8.2 Three distinct guarantees + +Documentation and tools must distinguish: + +1. **Disabled:** no annotations render, but code or strings may remain. +2. **Runtime excluded:** unused Stet JavaScript and CSS are absent from the output. +3. **Authoring content excluded:** temporary annotation copy, plans, and setup are + absent from the output. + +A no-op adapter can satisfy the first guarantee but cannot prove the third. + +### 8.3 Recommended first mechanism + +Temporary annotations should live behind an explicit module or component boundary. +The application selects that boundary with a build-time constant or conditional +entry. This allows the bundler to remove the module, its strings, and its imports. + +Agent Skills should guide agents to place temporary review annotations inside the +same boundary instead of scattering them through production components. + +### 8.4 Vite direction + +A thin Vite integration may automate the explicit boundary and provide output +diagnostics. It must not initially attempt arbitrary removal from: + +- React JSX and hook ownership. +- Vue directives and single-file component templates. +- Svelte actions. +- Angular directive imports, metadata, and templates. + +Generic textual or AST stripping across five compilers is high-risk and +unnecessary for the first useful result. The plain boundary recipe remains the +baseline; retain a plugin only if it is measurably easier and output tests prove +its guarantees. + +### 8.5 Build invariants + +- Enabled builds render annotations. +- Disabled temporary boundaries leave native application behavior unchanged. +- Temporary copy and plans are absent from the disabled output. +- Stet runtime and CSS are absent when no retained annotation imports them. +- Mixed builds retain intentional production annotations. +- SSR and hydration remain valid. +- Output inspection tests the promise rather than assuming tree-shaking worked. + +## 9. Agentic delivery contract + +Agent support is not a later documentation task. A public capability is complete +only when both authoring paths can use it correctly. + +For every primitive or option, completion includes as applicable: + +- Runtime API and TypeScript declarations. +- Vanilla and supported framework adapter behavior. +- Generated capability and annotation-plan schema updates. +- Canonical snippets and lifecycle patterns. +- Agent Skill guidance. +- A runnable verification example. +- Known limitations and recovery guidance. + +Temporal orchestration should begin as canonical code and skill guidance. Do not +add a general timeline language to annotation plans until real workflows require +serialization rather than executable host code. + +Agent delivery should support two explicit choices: + +- **Source-authored:** the explanation belongs in the project or reusable preview. +- **Browser-injected:** the explanation is temporary and primarily becomes an + inspectable artifact. + +The skill must choose based on intended lifetime, not simply prefer the newest +integration. + +## 10. Positioning and website direction + +### 10.1 Category and value + +**Category definition** + +> Stet is a code-native annotation library for explaining live interfaces with +> expressive marks, notes, and optional animation. + +**Value proposition** + +> Explain controls, states, and relationships directly in working websites, +> demonstrations, and presentations—by hand or with a coding agent. + +**Recommended hero** + +> **Explain the UI. Keep it interactive.** +> +> Add hand-drawn circles, arrows, notes, and animated reveals to working +> interfaces. Write them yourself or ask your coding agent; publish them in the +> site or capture them with Playwright. + +### 10.2 Equal authoring paths + +The website should present two equally visible starts: + +- **Write it yourself:** install, attach one annotation, control its motion. +- **Ask your agent:** install project skills, request an explanation, inspect the + result and verification evidence. + +Both paths must land on the same live example and public runtime rather than +appearing to be separate products. + +### 10.3 Proof order + +1. A state-dependent live explanation. +2. A short reason it beats a screenshot for that example. +3. Developer and agent starting paths. +4. Animation and presentation examples. +5. Playwright source-free delivery. +6. Compatibility, size, accessibility behavior, and limits. +7. Playground and broader visual gallery. + +The existing extended decorative gallery should be condensed or moved below the +task-focused proof. It demonstrates taste but not recurring value. + +### 10.4 Honest comparison copy + +**Why not screenshots?** + +> Use a screenshot when a still image is enough. Use Stet when the explanation +> depends on clicking, typing, changing state, resizing, or revealing ideas in +> sequence. Playwright can still capture the final annotated state when you need a +> portable artifact. + +**Why Stet for agents?** + +> Stet gives coding agents an installed API contract, framework examples, +> structured plans, and verification guidance. The agent writes or injects the +> annotations; your existing browser and test tools check the application. Stet +> does not run a model or turn an annotation into a QA result. + +### 10.5 Recommended primary demonstrations + +1. **Why is Save disabled?** Change state and reveal the relevant condition. +2. **The validation message exists, but focus is wrong.** Compare buggy and fixed + interactions while annotations explain the difference. +3. **This setting changes that result.** Reveal an arrow between control and + consequence. +4. **Presentation sequence.** Show a small ordered explanation controlled by the + host page. +5. **Agent handoff without source changes.** Use Playwright to inject, explain, + verify, capture, and clean up. + +## 11. Distribution and adoption + +### 11.1 Initial channels + +| Audience | Message | Artifact | Desired action | +| --- | --- | --- | --- | +| Storybook maintainers | Explain a component state next to the working component | One complete story recipe | Retain Stet in one real story | +| Interactive-doc authors | Use live annotations when screenshots lose behavior | Runnable starter | Publish an annotated example | +| Rough Notation users | Compare lifecycle, relationships, motion, and framework support | Honest migration example | Evaluate Stet in an existing project | +| Coding-agent users | Ask the agent to leave an inspectable UI explanation | Prompt, diff, preview, and retained failure | Repeat on a second task | +| Playwright users | Reach, explain, check, and capture in one browser workflow | Source-free injection recipe | Produce a real artifact | +| Presentation authors | Add finite hand-drawn reveals without adopting a tour engine | Host-controlled sequence example | Use it in a live presentation | + +Specific working artifacts should lead distribution. Generic social promotion, +framework-wide announcement blasts, and unexplained benchmark claims should not. + +### 11.2 Success measures + +The primary measures are: + +- Successful first annotation. +- Successful first injected artifact. +- Retained annotation after 30 days. +- Use on a second example or task. +- Public external examples. +- Qualified issues and reproductions. +- Non-maintainer contributions. + +Supporting funnel measures include visitor-to-GitHub, install-command copies, +framework selection, starter completion, playground code copy, and agent-skill +guide completion. + +Stars and raw npm downloads indicate awareness at best. Public npm totals cannot +reliably distinguish users, CI, mirrors, and maintainer activity. + +### 11.3 Privacy + +- No runtime or CLI telemetry by default. +- Website events contain no copied code, selectors, annotation text, or form data. +- Local traffic remains excluded. +- First success and repeated use are collected through voluntary confirmation, + consented pilots, public examples, and issue/discussion activity. + +## 12. Demand experiments + +Use one initial cohort of roughly twelve to sixteen qualified participants where +possible: primarily documentation/Storybook authors and Playwright-using agent +adopters. The audience counts below describe eligible observations, not separate +additive recruitment quotas. Rough Notation migration is a later specialist +cohort. The shared 30-day target is eight completed workflows, four retained uses, +and two second uses. + +### Experiment 1: interactive documentation adoption + +- **Hypothesis:** documentation authors retain annotations when they clarify live + state or behavior. +- **Audience:** ten maintainers with an existing interactive example. +- **Artifact:** one starter and optional short assisted setup. +- **Channel:** direct, relevant outreach and the Storybook community. +- **Metric:** four published/retained examples and two second uses within 30 days. +- **Time box:** three weeks plus 30-day follow-up. +- **Decision:** continue the wedge if repeated use occurs; do not infer demand from + playground use alone. + +### Experiment 2: live explanation versus screenshot + +- **Hypothesis:** live annotations improve understanding for state-dependent tasks. +- **Audience:** eight to twelve developers reviewing unfamiliar UI. +- **Artifact:** matched screenshot/prose and live-annotation explanations. +- **Channel:** recruited review sessions. +- **Metric:** task accuracy, time, missed behavior, and preference. +- **Time box:** two weeks. +- **Decision:** claim an advantage only for task classes where live interaction + wins without increasing errors. + +### Experiment 3: Storybook installation + +- **Hypothesis:** an existing component workshop removes enough adoption friction. +- **Audience:** eight Storybook users. +- **Artifact:** a recipe using the published package, not a new addon. +- **Channel:** Storybook community and targeted maintainer outreach. +- **Metric:** four unassisted completions within fifteen minutes and two retained + stories. +- **Time box:** two weeks. +- **Decision:** build an addon only if retained users repeatedly request the same + missing control. + +### Experiment 4: source versus injected agent handoff + +- **Hypothesis:** both delivery modes improve review over an ordinary summary, and + users choose them based on intended lifetime. +- **Audience:** six frontend engineers using coding agents and Playwright. +- **Artifact:** matched UI tasks with ordinary, source-authored, and injected + handoffs. +- **Channel:** direct recruitment from agent-tool communities. +- **Metric:** correction time, false claims, review accuracy, setup cost, and reuse. +- **Time box:** three weeks. +- **Decision:** keep both paths prominent only if each has a repeated job; simplify + to the winning path if one is consistently unnecessary. + +### Experiment 5: Rough Notation migration + +- **Hypothesis:** lifecycle, framework support, relationships, and modern motion + create a noncosmetic switching reason. +- **Audience:** five current Rough Notation or custom-overlay users. +- **Artifact:** behavior comparison and migration example. +- **Channel:** permission-based direct outreach and problem-oriented search pages. +- **Metric:** two real evaluations and one retained switch naming a noncosmetic + benefit. +- **Time box:** three weeks. +- **Decision:** invest in migration tooling only after a real retained switch. + +### Experiment 6: preview-only exclusion + +- **Hypothesis:** teams will use agent-authored annotations more often if temporary + content is provably absent from production. +- **Audience:** six teams that deploy frontend previews. +- **Artifact:** explicit module-boundary recipe with output inspection. +- **Channel:** the agent-handoff pilot. +- **Metric:** four successful integrations and no temporary content in inspected + production outputs. +- **Time box:** three weeks. +- **Decision:** retain a Vite plugin only if it substantially reduces setup or + errors compared with the plain recipe. + +## 13. Release principles and gates + +The first direction release is complete when it provides a coherent workflow, not +when every Rough Notation option has been copied. + +### 13.1 Must ship + +- **Release integrity:** A1 and A6. +- **Stable contracts:** A3 and A4. +- **Measured payloads:** A5, with provisional measurements after the motion + prototype and final release budgets after required `box` support is integrated. +- **Motion foundation:** B1, B2, B4, B5, and B7. The minimum animated + primitives are `circle`, `underline`, and the new `box`; static behavior remains + available for every existing primitive. Unsupported reveal combinations must be + rejected or omitted rather than silently doing the wrong thing. +- **One missing baseline primitive:** C1 (`box`). Other Rough Notation coverage is + part of the direction but not a condition for this release. +- **Playwright delivery:** D1–D4, D6, and D7, including browser injection without + application source edits. +- **Shipping control:** E1, E2, and E4. A plain explicit boundary is sufficient; + no plugin is required. +- **Equal agent delivery:** F1, the motion portion of F2, F3, and F4. +- **Public first success:** G1 and G2 plus the complete D7 handoff example. +- **Release verification:** H2 and current compatibility, accessibility, + placement, and injection limits. + +Sequential presentation is possible in this cut by awaiting individual handles in +host code. A group helper and video-specific recipe improve ergonomics but do not +block the Playwright injection commitment. + +### 13.2 Ship if ready, otherwise follow immediately + +- Primitive-specific reveal for highlight, arrow, and sticky (B3). +- Public update APIs and mutable behavior beyond the internal nonreplaying updates + required by A4/B7 (remaining B6 scope). +- Ordered group helper (B8). +- Bracket, strike-through, padding, multiline/RTL, and iterations (C2–C6). +- Ordered Playwright video recipe (D5). +- Fresh comparative agent trials (F5/F6). +- The broader example set (G3/G4). + +### 13.3 Not required + +- Universal compiler stripping. +- Hosted artifact sharing. +- Collaboration or identity. +- Browser extension or MCP server. +- Tour progression. +- Arbitrary animation timelines. +- Automatic target discovery across source and browser runtimes. +- Automatic QA or explanation generation. + +### 13.4 Quality gates + +- Narrow unit tests for new state and geometry behavior. +- Browser tests for reveal, reduced motion, refresh without replay, and cleanup. +- Framework lifecycle and SSR/hydration checks. +- Packed-consumer tests for every new export. +- Generated capability/schema drift checks. +- Browser-bundle and npm-package size reporting. +- Output inspection for disabled and mixed builds. +- Manual visual review of each primitive's animation. +- Retained agent failures and corrected results kept separate. + +## 14. Dependency-ordered backlog + +Impact, confidence, and effort use High/Medium/Low. Effort is relative: Small is +up to roughly two focused days, Medium is several days to a week, and Large is +more than a week or crosses several toolchains. Estimates exclude waiting for +external pilot feedback. + +### Now: establish contracts and release integrity + +#### A1. Make public examples match the published package + +- **Problem:** the deployed website can copy checkout-only placement options while + advertising published `0.1.0`. +- **Proposed change:** build stable public examples from the released package and + capability snapshot; isolate any next-version preview visibly. +- **User and workflow:** first-time visitor copying an example into an installed + project. +- **Rationale:** a version mismatch blocks first success and invalidates adoption + measurement. +- **Acceptance criteria:** every stable snippet compiles against the advertised + version; stable runtime and capabilities agree; unreleased options cannot appear + in copied stable output; version labels agree across site, README, and npm links. +- **Verification:** packed-consumer compile plus website build and targeted snippet + assertions. +- **Impact / confidence / effort:** High / High / Medium. +- **Dependencies:** none. +- **Purpose:** validates demand by removing a known conversion defect. + +#### A2. Publish the Rough Notation behavior target + +- **Problem:** “port as much as possible” can become an unbounded compatibility + project. +- **Proposed change:** turn Section 5 into a versioned capability checklist with + present, planned, equivalent, and intentionally excluded states. +- **User and workflow:** adopter comparing libraries; contributor selecting work. +- **Rationale:** behavioral parity is useful; accidental API cloning is not. +- **Acceptance criteria:** every public Rough Notation capability has a disposition; + equivalence claims have examples; drop-in compatibility is explicitly denied. +- **Verification:** documentation review against Rough Notation's current README + and source. +- **Impact / confidence / effort:** High / High / Small. +- **Dependencies:** none. +- **Purpose:** validates switching demand and controls scope. + +#### A3. Decide the visibility and animation lifecycle + +- **Problem:** show, hide, replay, completion, and interruption affect every + primitive and adapter. +- **Proposed change:** write an API decision record with state transitions, + reduced-motion behavior, completion, cancellation, and error semantics. +- **User and workflow:** developer or agent sequencing annotations. +- **Rationale:** adding CSS before defining state would create incompatible behavior. +- **Acceptance criteria:** initial visibility; repeated calls; hide/destroy during + reveal; offscreen targets; refresh; resketch; and reduced motion all have one + deterministic outcome. +- **Verification:** executable state-table test plan accompanies the record. +- **Impact / confidence / effort:** High / High / Small. +- **Dependencies:** none. +- **Purpose:** enables the first release safely. + +#### A4. Decide framework visibility and update ergonomics + +- **Problem:** current adapters remount on option changes and expose no shared + imperative animation control. +- **Proposed change:** define idiomatic React, Vue, Svelte, Angular, and vanilla + examples using one core handle contract. +- **User and workflow:** framework developer toggling or replaying an annotation. +- **Rationale:** animation parity cannot be delegated to later adapter cleanup. +- **Acceptance criteria:** every framework can declaratively show/hide and + imperatively sequence where appropriate; updates do not replay accidentally; + no speculative framework abstraction is introduced. +- **Verification:** prototype compilation and lifecycle test design for each + adapter. +- **Impact / confidence / effort:** High / Medium / Medium. +- **Dependencies:** A3. +- **Purpose:** enables adoption across existing supported frameworks. + +#### A5. Define size measurements and provisional budgets + +- **Problem:** the current core is approximately 6.83 KiB against a 7 KiB budget, + leaving no honest room for the direction. +- **Proposed change:** report a minimal primitive consumer, full core, CSS, and + optional integrations separately; preserve provisional regression limits, then + finalize them after the first motion prototype. +- **User and workflow:** developer evaluating production cost; maintainer reviewing + regressions. +- **Rationale:** preserve tree-shaking instead of optimizing for one concatenated + number. +- **Acceptance criteria:** measurement categories and provisional limits are + reproducible in CI; optional Playwright/build code cannot enter browser-core + bundles; B2 records any evidence-based final limit change. +- **Verification:** extend package bundle fixtures and size script. +- **Impact / confidence / effort:** Medium / High / Small. +- **Dependencies:** none; final thresholds follow B2. +- **Purpose:** scales trusted adoption. + +#### A6. Clarify install, binary, ESM, CSS, and platform requirements + +- **Problem:** scoped package installation, the `stet` binary, CSS import, ESM, and + Node requirements are not always presented together. +- **Proposed change:** establish one canonical install sequence and reuse it across + README, website, skills, and docs. +- **User and workflow:** new developer or agent initializing Stet. +- **Rationale:** remove preventable first-run ambiguity. +- **Acceptance criteria:** examples establish the scoped package before local CLI + use; Node and client-only requirements are visible; package consumers exercise + the documented path. +- **Verification:** packed fresh-consumer test on supported Node versions. +- **Impact / confidence / effort:** High / High / Small. +- **Dependencies:** A1. +- **Purpose:** validates activation. + +### Next: build the motion foundation + +#### B1. Add explicit annotation visibility + +- **Problem:** annotations cannot be hidden without destruction, and observer-owned + visibility cannot represent user intent. +- **Proposed change:** add public visibility state while keeping intersection state + separate. +- **User and workflow:** site, demo, presentation, or agent handoff toggling marks. +- **Rationale:** visibility is the foundation for reveal, replay, and sequencing. +- **Acceptance criteria:** explicit hiding survives intersection changes; showing + remeasures; descriptions follow the agreed accessibility contract; existing + immediate rendering stays the default. +- **Verification:** unit state test and browser scroll/description regression. +- **Impact / confidence / effort:** High / High / Medium. +- **Dependencies:** A3. +- **Purpose:** delivers core first-release value. + +#### B2. Add opt-in reveal for stroked primitives + +- **Problem:** boil gives ambient movement but cannot direct attention through a + finite entrance. +- **Proposed change:** implement the agreed reveal behavior for underline and + circle first, then other compatible strokes. +- **User and workflow:** developer presenting or demonstrating an interface. +- **Rationale:** finite reveal is the central product expansion. +- **Acceptance criteria:** static default unchanged; duration and delay work; + refresh/scroll/resize do not replay; reduced motion shows the final state + immediately. +- **Verification:** one unit state test, browser animation test, and manual visual + review. +- **Impact / confidence / effort:** High / High / Medium. +- **Dependencies:** B1. +- **Purpose:** validates animation demand. + +#### B3. Add primitive-appropriate highlight, arrow, and sticky reveal + +- **Problem:** a generic outline animation produces poor results for filled marks, + compound arrows, and text notes. +- **Proposed change:** add a marker sweep, intentional shaft/head order, and + coherent note entrance. +- **User and workflow:** author explaining state, relationship, and meaning. +- **Rationale:** the differentiating primitives must not look like unfinished + exceptions. +- **Acceptance criteria:** each treatment reaches a stable final state; labels and + descriptions remain readable; no outline-only highlight; no layout ownership. +- **Verification:** targeted browser tests and approved visual references for each + treatment. +- **Impact / confidence / effort:** High / Medium / Large. +- **Dependencies:** B2. +- **Purpose:** deepens demonstrated value. + +#### B4. Add replay, completion, and cancellation + +- **Problem:** consumers otherwise reproduce timing with fragile timeouts. +- **Proposed change:** implement the A3 completion contract and explicit replay. +- **User and workflow:** presenter, demo author, agent, or Playwright capture + sequencing marks. +- **Rationale:** awaitable behavior is the main API modernization over Rough + Notation. +- **Acceptance criteria:** completion settles exactly once; hide/destroy and + interrupted replay never hang; reduced motion completes immediately; errors are + actionable. +- **Verification:** state-machine unit test using fake events/time plus browser + interruption case. +- **Impact / confidence / effort:** High / Medium / Medium. +- **Dependencies:** B2. +- **Purpose:** enables sequencing and Playwright delivery. + +#### B5. Compose reveal, boil, hover, and reduced motion + +- **Problem:** independent motion mechanisms can flash variants, restart, or run + forever during capture. +- **Proposed change:** define and implement one composition policy. +- **User and workflow:** animated live site and deterministic artifact capture. +- **Rationale:** animation polish is irrelevant if lifecycle behavior is unstable. +- **Acceptance criteria:** no variant flash; hover resketch does not replay; + reduced-motion changes settle correctly; capture can reach a deterministic + state. +- **Verification:** browser tests for live preference changes, hover, replay, and + stable capture. +- **Impact / confidence / effort:** High / Medium / Medium. +- **Dependencies:** B4. +- **Purpose:** retains users who adopt motion. + +#### B6. Add only the stable update behavior animation requires + +- **Problem:** adapter remounts can restart reveal and lose handle state. +- **Proposed change:** add a typed update path for proven mutable fields, or retain + remounting where it has no harmful effect. +- **User and workflow:** framework application changing theme, note text, or + visibility. +- **Rationale:** solve the concrete lifecycle problem without cloning mutable + property setters. +- **Acceptance criteria:** supported updates preserve visibility and do not replay; + unsupported structural changes are explicit; cleanup remains idempotent. +- **Verification:** adapter update tests and handle-state regression. +- **Impact / confidence / effort:** Medium / Medium / Medium. +- **Dependencies:** A4, B4. +- **Purpose:** scales reliable framework usage. + +#### B7. Deliver framework parity for motion + +- **Problem:** core-only motion would make the advertised adapters misleading. +- **Proposed change:** implement A4 for the first-release visibility/reveal contract + across all current adapters and templates; later primitive treatments and update + operations carry their own adapter changes. +- **User and workflow:** React, Vue, Svelte, Angular, and vanilla consumers. +- **Rationale:** the existing support promise includes those frameworks. +- **Acceptance criteria:** matching supported options and lifecycle outcomes; + StrictMode, reactive updates, action/directive cleanup, and hydration still pass. +- **Verification:** existing adapter, pattern, browser, and SSR suites extended + narrowly. +- **Impact / confidence / effort:** High / High / Large. +- **Dependencies:** B1, B2, B4, B5. +- **Purpose:** scales the first-release capability. + +#### B8. Add ordered annotation groups + +- **Problem:** presentations and demonstrations need sequencing without manual + duration arithmetic. +- **Proposed change:** add a small coordinator over stable handles. +- **User and workflow:** author revealing several annotations in order. +- **Rationale:** this is a common annotation behavior and a direct beneficiary of + awaitable completion. +- **Acceptance criteria:** ordered reveal, hide/reset, replay, cancellation, and + cleanup; host app still owns slides, navigation, and progression. +- **Verification:** deterministic order/cancellation unit test and one browser demo. +- **Impact / confidence / effort:** High / Medium / Medium. +- **Dependencies:** B4, B7. +- **Purpose:** validates presentation and demonstration demand. + +### Next: complete useful Rough Notation coverage + +#### C1. Add `box` + +- **Problem:** rectangular controls, cards, and regions lack a familiar annotation. +- **Proposed change:** add seeded rough-box geometry and named exports/adapters. +- **User and workflow:** author emphasizing a bounded UI region. +- **Rationale:** common migration and presentation primitive. +- **Acceptance criteria:** padding, reveal, refresh, descriptions, generated + capabilities, all adapters, and tree-shaking work. +- **Verification:** geometry/unit, browser visual, adapter, packed-consumer, and + generated-drift checks. +- **Impact / confidence / effort:** High / High / Medium. +- **Dependencies:** B2, B7. +- **Purpose:** validates migration demand. + +#### C2. Add `bracket` + +- **Problem:** explanatory prose and grouped regions benefit from side-specific + grouping without a full box. +- **Proposed change:** add bracket geometry with selected top/right/bottom/left + sides. +- **User and workflow:** documentation and presentation author grouping content. +- **Rationale:** useful expressive behavior not covered by current primitives. +- **Acceptance criteria:** one or more validated sides; deterministic reveal; + padding and wrapped-content behavior; adapter and agent parity. +- **Verification:** invalid-input test, directional visual references, browser + lifecycle, and template compilation. +- **Impact / confidence / effort:** Medium / Medium / Medium. +- **Dependencies:** B2, B7, C4. +- **Purpose:** deepens demonstrated value. + +#### C3. Add `strikeThrough` and document crossed-off behavior + +- **Problem:** correction, before/after, and rejected-choice examples lack a clear + strike primitive. +- **Proposed change:** add strike-through and document how existing wrong marks map + to crossed-off behavior. +- **User and workflow:** author showing deprecated or incorrect content. +- **Rationale:** common annotation vocabulary with little need for new architecture. +- **Acceptance criteria:** wrapped-text behavior defined; reveal direction works; + `mark("wrong")` compatibility differences are explicit. +- **Verification:** multiline/RTL visual and adapter tests. +- **Impact / confidence / effort:** Medium / High / Small–Medium. +- **Dependencies:** B2, B7. C5 expands its text-direction coverage but does not + block the basic primitive. +- **Purpose:** scales primitive usefulness. + +#### C4. Add CSS-like per-side padding + +- **Problem:** scalar padding cannot fit asymmetric labels and layout edges. +- **Proposed change:** accept documented CSS-like shorthand and normalize once in + shared geometry code for primitives where padding already has defined meaning; + decide highlight wash geometry separately rather than implying its current + overlay padding already controls the wash edges. +- **User and workflow:** author fitting marks around real content. +- **Rationale:** common usability improvement with shared implementation. +- **Acceptance criteria:** supported scalar and shorthand forms are typed and + validated; negative/invalid values have explicit behavior; existing scalar + output is unchanged. +- **Verification:** one normalization unit table and representative visuals. +- **Impact / confidence / effort:** Medium / High / Small. +- **Dependencies:** none. +- **Purpose:** scales proven placement needs. + +#### C5. Specify multiline and RTL behavior + +- **Problem:** current line splitting is implicit and drawing direction is not + configurable. +- **Proposed change:** define supported text elements, multiline choice, RTL reveal, + and unsupported writing modes before adding options. +- **User and workflow:** documentation author annotating wrapped/localized text. +- **Rationale:** behavior should follow actual layout, not a copied boolean alone. +- **Acceptance criteria:** LTR/RTL and single/multiline examples; direction affects + reveal intentionally; unsupported vertical writing is documented. +- **Verification:** browser fixtures for wrapping, mixed inline text, and RTL. +- **Impact / confidence / effort:** Medium / Medium / Medium. +- **Dependencies:** B2. +- **Purpose:** scales international and documentation use. + +#### C6. Add configurable iterations + +- **Problem:** authors may want one or several pen passes independent of boil. +- **Proposed change:** add bounded deterministic stroke iterations to compatible + primitives. +- **User and workflow:** author tuning appearance and reveal. +- **Rationale:** closes a familiar capability gap while preserving deterministic + output. +- **Acceptance criteria:** iterations are distinct from boil variants; input is + bounded; animation timing is defined; bundle and DOM cost are measured. +- **Verification:** deterministic geometry test, visual references, and size report. +- **Impact / confidence / effort:** Low–Medium / Medium / Medium. +- **Dependencies:** B5 and evidence from animation pilots. +- **Purpose:** scales proven customization; may move after release. + +#### C7. Publish an honest Rough Notation migration guide + +- **Problem:** potential switchers cannot evaluate API and behavior differences. +- **Proposed change:** provide side-by-side released examples and a concise mapping. +- **User and workflow:** existing Rough Notation consumer evaluating Stet. +- **Rationale:** reach an established category instead of inventing search demand. +- **Acceptance criteria:** setup, primitive, animation, grouping, updates, bundle, + accessibility, and limitations compared without unsupported superiority claims. +- **Verification:** every snippet compiles against published packages. +- **Impact / confidence / effort:** Medium / Medium / Medium. +- **Dependencies:** released subset of C1–C6. +- **Purpose:** validates and then scales switching demand. + +### First direction release: Playwright delivery + +#### D1. Define browser-injection support and ownership + +- **Problem:** injection spans package, browser, document, frame, navigation, and + cleanup boundaries. +- **Proposed change:** write the support contract and minimal API decision record. +- **User and workflow:** Playwright user or agent adding temporary annotations. +- **Rationale:** injection failures can pollute pages or create misleading artifacts. +- **Acceptance criteria:** browser/document/frame/CSP behavior, error handling, + target lifetime, ownership, and cleanup are explicit. +- **Verification:** acceptance fixture matrix accompanies the decision. +- **Impact / confidence / effort:** High / High / Small. +- **Dependencies:** A3. +- **Purpose:** enables first-release agentic delivery. + +#### D2. Implement optional Playwright browser injection + +- **Problem:** temporary handoffs currently require application source changes. +- **Proposed change:** provide a recipe or optional entry point that injects the + released browser runtime/CSS and resolves supplied Playwright locators. +- **User and workflow:** agent or developer annotating a running preview for capture. +- **Rationale:** source-free delivery materially broadens legitimate use. +- **Acceptance criteria:** no app source edits; actionable missing/multiple-target + errors; no core Playwright dependency; types and packaging work in a fresh + consumer. +- **Verification:** packed Playwright consumer against a local fixture in supported + engines. +- **Impact / confidence / effort:** High / Medium / Large. +- **Dependencies:** D1, B4. +- **Purpose:** validates the agent and Playwright wedge. + +#### D3. Add scoped cleanup and navigation behavior + +- **Problem:** injected annotations must not remove application-authored marks or + leak after navigation/failure. +- **Proposed change:** assign integration ownership and expose idempotent cleanup. +- **User and workflow:** test creating several temporary annotation sets. +- **Rationale:** safe teardown is mandatory for browser tooling. +- **Acceptance criteria:** cleanup removes only owned overlays/descriptions/listeners; + application-authored Stet remains; navigation and test failure leave no retained + page state. +- **Verification:** mixed ownership and navigation browser tests. +- **Impact / confidence / effort:** High / High / Medium. +- **Dependencies:** D2. +- **Purpose:** retains integration users. + +#### D4. Publish deterministic screenshot capture + +- **Problem:** animation, fonts, random geometry, and responsive state can make + artifacts unstable. +- **Proposed change:** provide a canonical source-authored and injected screenshot + recipe. +- **User and workflow:** developer or agent attaching an annotated screenshot to a + release or review. +- **Rationale:** use Playwright's existing capture rather than building export. +- **Acceptance criteria:** explicit viewport/state; stable seed; fonts ready; + finite reveal complete; boil controlled; plain-text context included. +- **Verification:** repeat capture has stable expected output on the supported CI + platform. +- **Impact / confidence / effort:** High / High / Small–Medium. +- **Dependencies:** B4, D2. The first recipe disables boil; B5 later broadens + deterministic capture while ambient motion is enabled. +- **Purpose:** validates artifact sharing. + +#### D5. Publish ordered video and presentation capture + +- **Problem:** authors need to record intentional reveal order without duplicating + timing values. +- **Proposed change:** demonstrate Playwright recording controlled by handle/group + completion. +- **User and workflow:** release-demo or presentation author. +- **Rationale:** shows the value of finite motion and ordered groups together. +- **Acceptance criteria:** recording uses public APIs; no arbitrary sleeps for Stet + completion; reduced-motion capture alternative documented. +- **Verification:** generated recording plus deterministic event-order assertion. +- **Impact / confidence / effort:** Medium / Medium / Medium. +- **Dependencies:** B8, D2. +- **Purpose:** validates demonstrative and presentational demand. + +#### D6. Publish independent behavior verification + +- **Problem:** attractive annotations can conceal broken native behavior or imply a + check that never ran. +- **Proposed change:** extract a minimal consumer recipe from existing verification + fixtures. +- **User and workflow:** developer or agent claiming an annotated handoff is usable. +- **Rationale:** verification is part of credible delivery. +- **Acceptance criteria:** check focus, typing, submission, control identity, + descriptions, pointer transparency, layout, and cleanup separately from visual + presence. +- **Verification:** runnable `@playwright/test` example in a packed consumer. +- **Impact / confidence / effort:** High / High / Medium. +- **Dependencies:** D3. +- **Purpose:** scales trusted use. + +#### D7. Produce one complete portable handoff artifact + +- **Problem:** plans and screenshots alone do not show the intended end-to-end + delivery. +- **Proposed change:** publish an example containing preview/state metadata, + annotation plan, screenshot/video, explanation, checks, and uncertainty. +- **User and workflow:** reviewer inspecting an agent-authored UI change. +- **Rationale:** demonstrate the product outcome rather than the tooling pieces. +- **Acceptance criteria:** generated from public APIs; no claim that Stet ran the + agent or QA; source-authored and injected variants compared honestly. +- **Verification:** another developer can reproduce it from documented commands. +- **Impact / confidence / effort:** High / Medium / Medium. +- **Dependencies:** D4, D6, and F3. The video recipe in D5 is not required. +- **Purpose:** validates agentic delivery and provides distribution. + +### First direction release: shipping control + +#### E1. Document production, preview, local, and mixed boundaries + +- **Problem:** “strip Stet in production” conflicts with intentional production + annotations and does not define content removal. +- **Proposed change:** publish one explicit annotation-module pattern for each mode. +- **User and workflow:** application deciding what belongs in customer builds. +- **Rationale:** shipping policy must be a user choice. +- **Acceptance criteria:** each mode has a minimal framework example; disabled, + runtime-excluded, and content-excluded guarantees are distinguished. +- **Verification:** examples compile and output expectations are stated. +- **Impact / confidence / effort:** High / High / Small. +- **Dependencies:** A4. +- **Purpose:** validates preview-only demand. + +#### E2. Add compile-time exclusion recipes + +- **Problem:** runtime no-ops can leave temporary copy and imports in production. +- **Proposed change:** implement explicit review-only modules selected through + compile-time constants/import boundaries. +- **User and workflow:** team deploying annotations to previews but not production. +- **Rationale:** let normal bundlers remove content without source rewriting. +- **Acceptance criteria:** enabled preview renders; disabled output contains no + temporary text, plan, Stet runtime, or CSS when otherwise unused; mixed mode + retains production marks. +- **Verification:** output inspection in fresh Vite consumers. +- **Impact / confidence / effort:** High / High / Medium. +- **Dependencies:** E1. +- **Purpose:** validates agentic preview adoption. + +### Later: evaluate build-tool convenience after the plain recipe + +#### E3. Prototype a bounded Vite integration + +- **Problem:** repeated manual boundary configuration may be an adoption obstacle. +- **Proposed change:** prototype a plugin that configures explicit supported + boundaries and reports what was excluded. +- **User and workflow:** Vite application using preview-only annotations. +- **Rationale:** test convenience without committing to universal compiler work. +- **Acceptance criteria:** no arbitrary JSX/SFC/template rewriting; plain recipe + remains possible; output equals E2; plugin retained only after user comparison. +- **Verification:** enabled, disabled, and mixed fixture builds plus pilot feedback. +- **Impact / confidence / effort:** Medium / Low–Medium / Medium. +- **Dependencies:** E2 and demand experiment 6. +- **Purpose:** validates demand; not a release blocker if the recipe wins. + +### First direction release: prove shipping guarantees + +#### E4. Gate exclusion claims with output tests + +- **Problem:** configuration can appear disabled while leaving reachable chunks or + private annotation content. +- **Proposed change:** inspect generated JavaScript, CSS, assets, and module graphs in + consumer fixtures. +- **User and workflow:** maintainer making a production-exclusion guarantee. +- **Rationale:** build output, not intent, determines what ships. +- **Acceptance criteria:** CI covers enabled, disabled, and mixed cases; temporary + sentinel content is absent; intentional marks remain in mixed mode. +- **Verification:** automated artifact scan and runtime smoke test. +- **Impact / confidence / effort:** High / High / Medium. +- **Dependencies:** E2; E3 if shipped. +- **Purpose:** scales trusted adoption. + +### Cross-cutting: keep agent delivery complete + +#### F1. Extend generated contracts with every released capability + +- **Problem:** runtime, capabilities, schemas, types, and templates can drift. +- **Proposed change:** update the existing generator catalog as each B/C feature + lands. +- **User and workflow:** coding agent inspecting installed facts. +- **Rationale:** generated installed-version truth is a substantive Stet advantage. +- **Acceptance criteria:** new primitives/options are represented; unsupported + temporal orchestration is not fabricated; `agent:check` passes. +- **Verification:** existing drift, schema, template, and package tests. +- **Impact / confidence / effort:** High / High / Small per capability. +- **Dependencies:** corresponding runtime work. +- **Purpose:** scales agent adoption. + +#### F2. Add canonical motion and group recipes + +- **Problem:** agents may invent lifecycle methods or coordinate with arbitrary + sleeps. +- **Proposed change:** generate canonical static, reveal, and replay examples for + supported frameworks; add ordered-group examples only if B8 ships. +- **User and workflow:** coding agent implementing a demo or presentation. +- **Rationale:** keep temporal behavior grounded in the public API. +- **Acceptance criteria:** examples compile; reduced-motion and cleanup guidance + included; sequential host-code example uses handle completion; no timeline + schema introduced. +- **Verification:** template compile matrix and browser lifecycle case. +- **Impact / confidence / effort:** High / High / Medium. +- **Dependencies:** B7, F1. B8 is required only for group-helper examples. +- **Purpose:** scales agentic authoring. + +#### F3. Teach source-authored versus injected delivery + +- **Problem:** agents need to choose lifetime and ownership rather than always edit + source. +- **Proposed change:** update entry and use-case skills with a bounded delivery + decision and both workflows. +- **User and workflow:** agent responding to “explain this UI change.” +- **Rationale:** makes Playwright injection a first-class delivery path without a + second product. +- **Acceptance criteria:** durable/published intent selects source; temporary + artifact intent selects injection; ambiguous intent is surfaced; generated + output states limits. +- **Verification:** positive, negative, and ambiguous routing/task fixtures. +- **Impact / confidence / effort:** High / Medium / Medium. +- **Dependencies:** D2, E1. +- **Purpose:** validates agentic delivery. + +#### F4. Teach production and review boundaries + +- **Problem:** an agent may scatter temporary review copy through shipping source. +- **Proposed change:** add explicit placement and output-verification guidance to + relevant skills. +- **User and workflow:** agent implementing preview-only annotations. +- **Rationale:** build exclusion depends on source ownership discipline. +- **Acceptance criteria:** task examples put temporary work inside the supported + boundary; agent verifies output rather than merely setting an environment flag. +- **Verification:** prepared application build with sentinel-content scan. +- **Impact / confidence / effort:** High / Medium / Small–Medium. +- **Dependencies:** E2, E4. +- **Purpose:** scales safe agent usage. + +#### F5. Run fresh source and injection trials + +- **Problem:** current trials do not evaluate new motion, injection, or shipping + choices. +- **Proposed change:** run matched tasks with retained initial outputs, corrections, + browser evidence, and ordinary-tool baselines. +- **User and workflow:** maintainer evaluating whether skills improve delivery. +- **Rationale:** test the actual first-release thesis. +- **Acceptance criteria:** tasks cover source and injected paths; matched output + requirements; initial failures retained; truthfulness, interaction, placement, + and build output scored separately. +- **Verification:** model-free replay plus independently reviewed raw artifacts. +- **Impact / confidence / effort:** High / Medium / Large. +- **Dependencies:** F2–F4, D7. +- **Purpose:** validates agent differentiation. + +#### F6. Publish a scoped evaluation summary + +- **Problem:** routing and final pass counts can be mistaken for general agent + performance. +- **Proposed change:** publish outcomes with sample, model, prompt, correction cost, + baseline, and review limits. +- **User and workflow:** adopter deciding whether to trust agent guidance. +- **Rationale:** transparent limits are a trust advantage. +- **Acceptance criteria:** forced-choice routing labeled accurately; fresh execution + distinguished from replay; corrected results separate from first attempts; no QA + claim follows from annotations. +- **Verification:** independent evidence-to-copy review. +- **Impact / confidence / effort:** Medium / High / Small. +- **Dependencies:** F5. +- **Purpose:** scales trust rather than raw demand. + +### Adoption surface + +#### G1. Reframe the homepage around live explanation + +- **Problem:** visual identity and broad audiences appear before a recurring task. +- **Proposed change:** lead with one state-dependent interactive demonstration and + the agreed category/value copy. +- **User and workflow:** developer deciding whether Stet solves a problem. +- **Rationale:** establish outcome before implementation mechanics. +- **Acceptance criteria:** brief user test identifies category, use, boundary, and + next action; decorative gallery is secondary; stable examples use the release. +- **Verification:** website tests plus five-second comprehension sessions. +- **Impact / confidence / effort:** High / Medium / Medium. +- **Dependencies:** A1 and a stable B/C release subset. +- **Purpose:** validates positioning. + +#### G2. Give developer and agent paths equal first success + +- **Problem:** agent tooling can feel separate while the direct API path is spread + across docs. +- **Proposed change:** provide parallel “Write it yourself” and “Ask your agent” + starts that converge on the same example. +- **User and workflow:** new developer choosing an authoring method. +- **Rationale:** agentic delivery remains first-class without becoming a separate + product. +- **Acceptance criteria:** both paths produce the same supported annotation; mobile + CTA remains visible; direct quickstart includes cleanup and motion choice. +- **Verification:** mobile/keyboard browser test and fresh-user completion. +- **Impact / confidence / effort:** High / High / Medium. +- **Dependencies:** A6, F2. +- **Purpose:** validates both acquisition paths. + +#### G3. Publish production, Storybook, presentation, and injection examples + +- **Problem:** the breadth of legitimate destinations is currently asserted more + than demonstrated. +- **Proposed change:** create four bounded examples by reusing existing fixtures and + primitives. +- **User and workflow:** developer matching Stet to an existing environment. +- **Rationale:** examples distribute the library better than abstract claims. +- **Acceptance criteria:** real controls; reduced motion; released APIs; visible + source; host owns progression; injection changes no source; each example states + when a screenshot is simpler. +- **Verification:** packed builds and targeted Playwright checks. +- **Impact / confidence / effort:** High / Medium / Large. +- **Dependencies:** B8, D7, E2. +- **Purpose:** validates and scales adoption. + +#### G4. Consolidate compatibility, limitations, and comparisons + +- **Problem:** browser, layout, injection, accessibility, release, and build limits + are fragmented. +- **Proposed change:** publish one linked compatibility/limits page and an honest + alternatives section. +- **User and workflow:** evaluator qualifying Stet before installation. +- **Rationale:** appropriate self-selection reduces failed adoption. +- **Acceptance criteria:** unsupported document/layout contexts; animation/reduced + motion; Playwright/CSP; ESM/framework versions; shipping guarantees; screenshot, + Rough Notation, tours, and feedback-tool boundaries are covered. +- **Verification:** claims traced to tests or explicitly labeled limitations. +- **Impact / confidence / effort:** Medium / High / Medium. +- **Dependencies:** final A–F contracts. +- **Purpose:** scales trustworthy adoption. + +#### G5. Add privacy-respecting funnel measurement + +- **Problem:** current website events do not show the path to first success. +- **Proposed change:** add aggregate GitHub, install, starter, playground-copy, + framework, and agent-guide events without content collection. +- **User and workflow:** maintainer diagnosing conversion friction. +- **Rationale:** distinguish awareness from attempted activation. +- **Acceptance criteria:** no copied code, selectors, annotation text, or form data; + local traffic excluded; event definitions documented; runtime remains telemetry + free. +- **Verification:** production-only event unit checks and privacy review. +- **Impact / confidence / effort:** Medium / High / Small. +- **Dependencies:** G1, G2 event locations. +- **Purpose:** validates demand. + +#### G6. Open contribution and example pathways + +- **Problem:** adopters lack a clear route to submit real examples or bounded + regressions. +- **Proposed change:** add accurate GitHub topics, example/reproduction templates, + and a small set of contribution-ready issues. +- **User and workflow:** adopter reporting friction or contributing a use case. +- **Rationale:** external artifacts and regressions are more meaningful than stars. +- **Acceptance criteria:** templates request versions, reproduction, expected + behavior, and permission for examples; no generic “help wanted” placeholders. +- **Verification:** dry-run a complete issue from the template. +- **Impact / confidence / effort:** Medium / Medium / Small. +- **Dependencies:** G3 for exemplar. +- **Purpose:** scales community participation. + +### Evidence gates + +#### H1. Run adoption and comparison experiments + +- **Problem:** implementation quality does not establish recurring demand. +- **Proposed change:** execute the six experiments in Section 12 with qualified + external users. +- **User and workflow:** maintainer deciding what to continue. +- **Rationale:** the next milestone is repeated use, not another capability. +- **Acceptance criteria:** participants, task, artifact, first success, rejection, + retention, and second use recorded with consent; thresholds applied as written. +- **Verification:** evidence ledger and follow-up dates. +- **Impact / confidence / effort:** High / High for learning / Large. +- **Dependencies:** relevant G examples and release candidates. +- **Purpose:** validates demand. + +#### H2. Complete the first-release consumer matrix + +- **Problem:** new motion, frameworks, injection, and exclusion cross several + toolchains. +- **Proposed change:** run the narrow test for each item and the full release matrix + before publication. +- **User and workflow:** every consumer installing the first direction release. +- **Rationale:** lazy code without its check is unfinished. +- **Acceptance criteria:** unit, browser, adapter, SSR/hydration, packed consumer, + schema/template, size, output exclusion, and manual visual review pass; failures + are not hidden by final reruns. +- **Verification:** release evidence links exact commands and artifacts. +- **Impact / confidence / effort:** High / High / Large. +- **Dependencies:** required first-release A–G items. +- **Purpose:** scales proven behavior. + +## 15. Suggested implementation sequence + +The dependency order is more important than parallel feature count: + +1. **Integrity:** A1, A2, A6. +2. **Contracts:** A3, A4, D1, E1; establish A5 measurements and + provisional budgets in parallel. +3. **Minimum motion:** B1 and B2. +4. **Early evidence:** run the live-versus-screenshot experiment on the B2 + prototype before committing to every primitive-specific treatment. +5. **Awaitable delivery:** B4, then D2 and D3. Injection does not wait for new + primitives, ordered groups, or every advanced animation. +6. **Safe shipping:** E2 and E4 plus F3/F4; evaluate E3 separately. +7. **First-release completeness:** B5, B7, C1, D4, D6, D7, and F1/F2 as each + public capability lands. +8. **Public first success:** G1 and G2, then H2. +9. **Immediate follow-ons:** B3, B6, B8, D5, C2–C5, and broader G examples, + ordered by pilot evidence. +10. **Demand and evaluation:** H1 and matched F5/F6 trials; scale only the paths + that users repeat. + +C6, additional migration tooling, and any richer build integration can move after +the first direction release if they do not strengthen its demonstrated workflows. + +## 16. 30/60/90-day adoption plan after release readiness + +### Days 1–30 + +- Fix release/site parity and ship the first-success paths. +- Publish the production, Storybook, presentation, and injection examples. +- Recruit qualified pilot users rather than a broad undifferentiated audience. +- Run the screenshot/live and source/injection comparisons. +- Record every installation failure and second use. + +Desired evidence: eight external projects complete a real workflow, four retain +annotations or artifacts, two use Stet on a second task, and users identify +benefits beyond appearance. These are the same participants used across the +experiments where qualified, not additive recruitment targets. + +### Days 31–60 + +- Fix only blockers reproduced in real projects. +- Publish two external examples with permission. +- Improve the channel and framework path that produced retained use. +- Run Rough Notation migration evaluations. +- Follow up on second-task use. + +Desired evidence: ten activated projects, five repeated-use projects, two public +external examples, and at least one meaningful external contribution or +reproduction. + +### Days 61–90 + +- Scale the acquisition path that produces retained use. +- Decide whether source-authored and injected delivery both deserve equal billing. +- Add another framework starter only after requests and a willing verifier. +- Set API stability priorities from actual consumer code. +- Enter maintenance mode rather than adding categories if retention is absent. + +Desired evidence: roughly twenty independently identifiable adopting projects, +eight repeated-use projects, and several unsolicited issues, artifacts, or +contributions. + +## 17. Continue, pivot, and stop criteria + +### Continue the direction when + +- Users complete and retain live annotations or injected artifacts. +- At least eight projects show second use within the first 90-day cohort. +- Users name comprehension, delivery, lifecycle, or review benefits beyond visual + novelty. +- External examples appear without the maintainer authoring all of them. +- One channel repeatedly produces retained use. + +### Keep both delivery paths when + +- Source-authored annotations recur in durable websites/docs/presentations. +- Injected annotations recur in temporary reviews or captured artifacts. +- Users choose based on lifetime rather than one path merely duplicating the other. + +If one path is consistently unnecessary, simplify positioning and maintenance +around the path users repeat. + +### Pivot toward the runtime when + +Users adopt primitives and motion but ignore plans and skills. Agent support can +remain optional authoring guidance without carrying the product story. + +### Pivot toward artifact recipes when + +Users value captured explanations but do not retain live annotation source. Keep +Stet as the renderer and let Playwright and existing systems own capture and +publishing. + +### Do not pivot automatically into feedback SaaS when + +Users ask for comments, assignment, identity, or recording. First determine +whether existing feedback tools already solve the requested job better. + +### Stop expanding and enter maintenance mode when + +After reaching approximately thirty qualified prospects and at least ten completed +trials: + +- Fewer than three retain Stet. +- Nobody uses it a second time. +- Most prefer screenshot and prose after successfully trying both. +- Value is consistently described only as “looks nice.” +- The maintainer must create every artifact and integration. +- Required requests converge on building a collaboration or tour platform. + +Low traffic alone is not a stopping signal. Repeated rejection after successful +activation is. + +## 18. Sources and research limits + +Primary sources accessed 2026-09-12: + +- Stet repository and implementation: +- Stet website: +- Rough Notation repository and API: +- Rough Notation SPA lifecycle issue: + +- Storybook testing documentation: +- Chromatic UI Review: +- Agentation: +- Agentation preview source-attribution request: + +- Playwright CLI: +- Playwright MCP: +- Excalidraw: + +This direction is based on repository inspection, public product evidence, public +issues, and market comparisons. It is not based on private analytics, user +interviews, independent accessibility certification, or measured search volume. +Competitor stars, downloads, and vendor claims do not represent unique active +users. The backlog intentionally treats external retention as the evidence needed +to confirm or change the direction. From 683092565bb53f8bad3bae7a815ad76b072ad97b Mon Sep 17 00:00:00 2001 From: Sai Nimmagadda Date: Sat, 12 Sep 2026 17:29:53 -0400 Subject: [PATCH 02/11] feat: phase 1 baseline --- docs/delivery/backlog.md | 66 +++++-- docs/delivery/contracts/adapters.md | 158 ++++++++++++++++ .../contracts/fixtures/adapter-types.ts | 21 +++ docs/delivery/contracts/fixtures/angular.ts | 24 +++ docs/delivery/contracts/fixtures/react.tsx | 17 ++ .../delivery/contracts/fixtures/svelte.svelte | 10 ++ .../delivery/contracts/fixtures/tsconfig.json | 10 ++ docs/delivery/contracts/fixtures/vue.vue | 13 ++ docs/delivery/contracts/playwright.md | 170 +++++++++++++++++- docs/delivery/contracts/runtime.md | 88 +++++++-- docs/delivery/contracts/shipping.md | 122 ++++++++++++- docs/delivery/evidence/BASE-01.md | 25 +++ docs/delivery/evidence/CONTRACT-01.md | 11 ++ docs/delivery/evidence/CONTRACT-02.md | 16 ++ docs/delivery/evidence/CONTRACT-03.md | 11 ++ docs/delivery/evidence/CONTRACT-04.md | 11 ++ docs/delivery/evidence/CONTRACT-05.md | 12 ++ docs/delivery/tasks/AGENT-01.md | 2 +- docs/delivery/tasks/AGENT-03.md | 3 +- docs/delivery/tasks/BASE-01.md | 2 +- docs/delivery/tasks/BOX-02.md | 6 +- docs/delivery/tasks/CONTRACT-01.md | 2 +- docs/delivery/tasks/CONTRACT-02.md | 2 +- docs/delivery/tasks/CONTRACT-03.md | 2 +- docs/delivery/tasks/CONTRACT-04.md | 2 +- docs/delivery/tasks/CONTRACT-05.md | 2 +- docs/delivery/tasks/CORE-01.md | 2 +- docs/delivery/tasks/CORE-05.md | 2 +- docs/delivery/tasks/CORE-07.md | 3 +- docs/delivery/tasks/MEASURE-01.md | 2 +- docs/delivery/tasks/PW-01.md | 2 +- docs/delivery/tasks/PW-03.md | 9 +- docs/delivery/tasks/SHIP-01.md | 2 +- docs/delivery/tasks/WEB-02.md | 2 +- 34 files changed, 773 insertions(+), 59 deletions(-) create mode 100644 docs/delivery/contracts/fixtures/adapter-types.ts create mode 100644 docs/delivery/contracts/fixtures/angular.ts create mode 100644 docs/delivery/contracts/fixtures/react.tsx create mode 100644 docs/delivery/contracts/fixtures/svelte.svelte create mode 100644 docs/delivery/contracts/fixtures/tsconfig.json create mode 100644 docs/delivery/contracts/fixtures/vue.vue create mode 100644 docs/delivery/evidence/BASE-01.md create mode 100644 docs/delivery/evidence/CONTRACT-01.md create mode 100644 docs/delivery/evidence/CONTRACT-02.md create mode 100644 docs/delivery/evidence/CONTRACT-03.md create mode 100644 docs/delivery/evidence/CONTRACT-04.md create mode 100644 docs/delivery/evidence/CONTRACT-05.md diff --git a/docs/delivery/backlog.md b/docs/delivery/backlog.md index 9fdedef..5d9f447 100644 --- a/docs/delivery/backlog.md +++ b/docs/delivery/backlog.md @@ -4,16 +4,16 @@ See [execution rules](README.md) and the approved [contracts](contracts/). | ID | Class | Parent | Direct dependencies | State | | --- | --- | --- | --- | --- | -| BASE-01 | Required | A1/A5/H2 | — | READY | -| CONTRACT-01 | Required | A3 | BASE-01 | BLOCKED | -| CONTRACT-02 | Required | A4/B6 | CONTRACT-01 | BLOCKED | -| CONTRACT-03 | Required | D1 | CONTRACT-01 | BLOCKED | -| CONTRACT-04 | Required | E1 | BASE-01, CONTRACT-02 | BLOCKED | -| CONTRACT-05 | Required | Release planning | CONTRACT-01..04 | BLOCKED | +| BASE-01 | Required | A1/A5/H2 | — | ACCEPTED | +| CONTRACT-01 | Required | A3 | BASE-01 | ACCEPTED | +| CONTRACT-02 | Required | A4/B6 | CONTRACT-01 | ACCEPTED | +| CONTRACT-03 | Required | D1 | CONTRACT-01 | ACCEPTED | +| CONTRACT-04 | Required | E1 | BASE-01, CONTRACT-02 | ACCEPTED | +| CONTRACT-05 | Required | Release planning | CONTRACT-01..04 | ACCEPTED | | FIX-01 | Required | A1 | BASE-01 | BLOCKED | | FIX-02 | Required | A6 | FIX-01 | BLOCKED | -| MEASURE-01 | Required | A5 | BASE-01 | BLOCKED | -| CORE-01 | Required | B1 | CONTRACT-01 | BLOCKED | +| MEASURE-01 | Required | A5 | BASE-01 | READY | +| CORE-01 | Required | B1 | CONTRACT-01 | READY | | CORE-02 | Required | B2/F1 | CORE-01 | BLOCKED | | CORE-03 | Required | B2 | CORE-02 | BLOCKED | | CORE-04 | Required | B2 | CORE-03 | BLOCKED | @@ -28,7 +28,7 @@ See [execution rules](README.md) and the approved [contracts](contracts/). | AD-INTEGRATE | Required | B7/F2 | All AD tasks | BLOCKED | | BOX-01 | Required | C1 | CORE-06 | BLOCKED | | BOX-02 | Required | C1/F1 | BOX-01, AD-INTEGRATE | BLOCKED | -| PW-01 | Required gate | D1/D2 | CONTRACT-03 | BLOCKED | +| PW-01 | Required gate | D1/D2 | CONTRACT-03 | READY | | PW-02 | Required gate | D1/D2/D3 | PW-01, CORE-05 | BLOCKED | | PW-03 | Required | D2 | PW-02 | BLOCKED | | PW-04 | Required | D2/D3 | PW-03 | BLOCKED | @@ -36,25 +36,55 @@ See [execution rules](README.md) and the approved [contracts](contracts/). | PW-06 | Required | D1/D3 | PW-05 | BLOCKED | | PW-07 | Required | D4 | PW-05, CORE-06 | BLOCKED | | PW-08 | Required | D6 | PW-05 | BLOCKED | -| SHIP-01 | Required | E2 | CONTRACT-04 | BLOCKED | +| SHIP-01 | Required | E2 | CONTRACT-04 | READY | | SHIP-02 | Required | E4 | SHIP-01 | BLOCKED | | SHIP-03 | Required | E1/F4 | SHIP-02 | BLOCKED | -| AGENT-01 | Required/final audit | F1 | AGENT-02, PW-03 | BLOCKED | +| AGENT-01 | Required/final audit | F1 | AGENT-02, PW-03, AGENT-03 | BLOCKED | | AGENT-02 | Required | F2 | AD-INTEGRATE, CORE-05, BOX-02 | BLOCKED | | AGENT-03 | Required | F3 | PW-06, SHIP-03 | BLOCKED | | HANDOFF-01 | Required | D7 | PW-07, PW-08, AGENT-03 | BLOCKED | | WEB-01 | Required | G1 | FIX-01, CORE-06 | BLOCKED | -| WEB-02 | Required | G2 | FIX-02, AGENT-02, HANDOFF-01 | BLOCKED | +| WEB-02 | Required | G2 | FIX-02, AGENT-02, HANDOFF-01, WEB-01 | BLOCKED | | DOCS-01 | Required | A2/G4 | BOX-02, PW-06, SHIP-03 | BLOCKED | | RELEASE-01 | Required gate | H2 | CONTRACT-05, FIX-01..02, MEASURE-01, CORE-01..08, all AD, BOX-01..02, PW-01..08, SHIP-01..03, AGENT-01..03, HANDOFF-01, WEB-01..02, DOCS-01 | BLOCKED | | RELEASE-02 | Required gate | A1/G1/G2 | RELEASE-01 | BLOCKED | | FOLLOW-01..14 | Conditional/post-release | B3/B8/C2-C6/D5/E3/G3 | See task files | BLOCKED | | EVIDENCE-01..08 | Human-led | G5/G6/H1/F5/F6 | See task files | BLOCKED | -## First dispatch +## Next dispatch -Run `BASE-01`, then prepare `CONTRACT-01` through `CONTRACT-04`, `FIX-01`, and -`MEASURE-01`. Runtime implementation starts only after its contracts are accepted. -Playwright implementation starts only after `PW-01` and `PW-02` produce accepted -evidence. Framework writers start only after the shared runtime and adapter -contract are stable. +`MEASURE-01`, `CORE-01`, `PW-01`, and `SHIP-01` are ready and may run subject to +the shared-file writer rules. `FIX-01` remains blocked on its explicit lead-approved +parity approach. Playwright implementation still starts only after `PW-01` and +`PW-02` produce accepted evidence. Framework writers start only after the shared +runtime and adapter contract are stable. + +## Graph audit + +The first-release graph is acyclic. Optional/post-release `FOLLOW-*` work and +human-led `EVIDENCE-*` work are not release prerequisites. In particular, +Playwright injection depends only on required contracts, CORE-05, and PW tasks; it +does not wait on optional primitive reveals, groups, extra primitives, video, a +Vite plugin, or broader examples. + +Implementation and verification ownership is unique by release area: FIX owns +A1/A6; MEASURE/CORE-08 own provisional/final size; CORE owns runtime motion; +AD-INTEGRATE verifies adapters; BOX-01/02 own geometry/public box; PW-01..06 own +injection and its permanent matrix, with PW-07/08/HANDOFF owning capture/handoff; +SHIP-01/02 own shipping fixtures/output proof and SHIP-03 owns published rules; +AGENT-01..03 own generated facts/examples/routing; WEB-01/02 own public proof/first +success; DOCS-01 and RELEASE-01 own final limits/integration verification. + +Generated files have one source writer at a time: CORE-02, BOX-02, PW-03 (package +export drift only), AGENT-02, AGENT-03 (routing outputs), then AGENT-01's final +audit. Generation always runs through `scripts/agent/generate.mjs`; generated files +are not hand-edited. `src/rough.ts` is written by motion tasks before BOX-01 and is +explicitly out of CORE-07 scope. Website work is serialized by WEB-01 before +WEB-02. + +The release critical path is the runtime chain through CORE-07, all adapter tasks, +AD-INTEGRATE, BOX-02, AGENT-02/01, plus parallel Playwright and shipping arms that +converge through AGENT-03, HANDOFF-01, WEB-02, DOCS-01, and RELEASE-01. FIX-01 has +an explicit non-task gate: its parity approach must be lead-approved. CORE-08 has +an explicit lead decision after measurement; neither approval may be inferred by +a worker. diff --git a/docs/delivery/contracts/adapters.md b/docs/delivery/contracts/adapters.md index f732e22..9ee3e4a 100644 --- a/docs/delivery/contracts/adapters.md +++ b/docs/delivery/contracts/adapters.md @@ -12,6 +12,11 @@ onHandle?: (handle: StetHandle | null) => void; `onHandle` is not a runtime option and never appears in plans or generated option schemas. +Adapter code removes `onHandle` before passing options to a primitive or the +internal update path. It is callable metadata, so JSON plans, capability schemas, +CLI snippets generated from plans, and serialized option snapshots never contain +it. + ## Shared rules - Notify with a handle after attachment and `null` when it becomes unavailable. @@ -24,6 +29,159 @@ schemas. - Unmount/destruction reports `null` and performs idempotent cleanup. - Existing SSR, hydration, StrictMode, directive, and action behavior remains. +Initial `visible` is passed into attachment; adapters do not call `show()` or +`hide()` again after mounting. On later renders/updates, only a changed boolean +value invokes the corresponding method exactly once. Omitted `visible` has the +runtime default `true`; changing from an explicit value to omission is a change to +`true`. This comparison is against the last declarative value, not current +imperative state, so unrelated updates cannot undo an imperative show/hide. + +Callbacks run after attachment and receive the complete handle. A target +replacement or unmount first destroys the old handle, then sends `null` to the +callback that received it. Attachment of a replacement then sends the new handle +to the current callback. Changing only callback identity sends `null` to the old +callback and the existing handle to the new callback without update, remount, or +adapter-initiated replay. Changing between omitted and present performs the +corresponding single notification. Authors must use a stable callback when a +non-null notification causes an imperative operation; the examples below do so. +Omitted callbacks do nothing. Adapters commit their own state before calling user +callbacks and do not swallow callback exceptions. + +All non-target runtime option changes use the source-only +`updateHandle(handle, nextOptions)` function from `src/mount.ts`. Adapters import +it directly; it is not re-exported by a package entry and is absent from +`StetHandle`. Each handle carries the module-private capability used by this +function; no handle-to-controller map or registry is created. `nextOptions` is a +complete snapshotted runtime option object excluding `visible` and `onHandle`, not +a patch, so removed values return to defaults. It applies appearance, +`resketchOnHover`, description, primitive-specific text/placement, seed, and +mark-kind changes without replacing the handle. It preserves +explicit visibility and any operation promise/deadline; a seed change redraws at +current progress. `animate`, `animationDuration`, and `animationDelay` are included +but affect only the next entrance. Declarative `visible` and adapter-only +`onHandle` are handled separately and never enter this operation. + +For arrows, changing `to` is target replacement. For React, changing the element +held by `target`, `from`, or `to` is target replacement even when ref-object +identity is stable. Other option changes, including sticky text/side, arrow label/ +curvature, and mark kind, are internal updates. + +Every adapter processes attachment and host updates with the same algorithm: + +1. Resolve every required target. With no live handle and any missing target, do + nothing and do not notify `null`. With a live handle and any missing target, + destroy it, notify the callback that received it with `null`, and stop. With no + live handle and all targets present, attach using current options (including + declarative visibility, omission meaning `true`), notify the current callback + with the handle, and stop without a preceding `null`. With a live handle and + changed target identity, destroy it, notify its callback with `null`, attach to + the new targets using current options, notify the current callback with the new + handle, and stop. Imperative visibility does not carry to a new handle. +2. Otherwise, if non-target runtime options changed, call `updateHandle` with the + complete current snapshot excluding `visible` and `onHandle`. +3. If declarative visibility changed, call `show()` or `hide()` once. This runs + after step 2 so new animation settings apply to that entrance. +4. If callback identity changed, perform the callback transfer above. A target + replacement already performed its notifications and does not repeat this step. + +Required targets are React `target`, and both `from` and `to` for React arrows; +arrow `to` for the other adapters; and the directive/action host, which exists +while Vue, Svelte, or Angular invokes the adapter. `updateHandle` receives only +primitive runtime options: adapters also strip `target`, `from`, and `to` along +with `visible` and `onHandle`. An unchanged arrow `to` is not an update field; a +changed `to` follows step 1. + +## Exact framework syntax + +### React 18+ + +Every component's props add `onHandle`; `visible` remains the flat runtime option. + +```tsx +const handle = useRef(null); +const onHandle = useCallback((next: StetHandle | null) => { + handle.current = next; +}, []); + + + +await handle.current?.replay(); +``` + +The callback prop is excluded from option equality. StrictMode setup/cleanup uses +the same destroy-then-`null` ordering; each delivered handle receives one matching +`null` notification. + +### Vue 3 directive + +Directive metadata shares the binding object but is stripped before runtime use. + +```vue + + + +``` + +The directive snapshots the unwrapped binding values. In-place reactive edits are +compared to that snapshot; callback identity transfer follows the shared rule. + +### Svelte 5 action + +`onHandle` is part of the action parameter metadata and is not forwarded. + +```svelte + + + +``` + +Action `update()` snapshots values and follows the shared changed-only rules. Its +`destroy()` destroys the handle before notifying `null`. + +### Angular standalone directive + +Angular uses a separate inherited callback input so the primary options input +stays serializable. The same input works with every Stet directive. + +```ts +handle: StetHandle | null = null; +readonly onStetHandle = (next: StetHandle | null) => { this.handle = next; }; +``` + +```html + +``` + +`stetOnHandle` implements the shared `onHandle` callback contract but is not part +of `StetOptions`. Angular follows the shared update algorithm and +destroy-then-`null` ordering. + Framework syntax must remain idiomatic for React 18+, Vue directives, Svelte actions, and Angular standalone directives. No adapter-specific animation engine or controller registry may be introduced. diff --git a/docs/delivery/contracts/fixtures/adapter-types.ts b/docs/delivery/contracts/fixtures/adapter-types.ts new file mode 100644 index 0000000..e06261f --- /dev/null +++ b/docs/delivery/contracts/fixtures/adapter-types.ts @@ -0,0 +1,21 @@ +import type { RefObject } from "react"; +import type { Action } from "svelte/action"; +import type { Directive } from "vue"; +import type { StetHandle, StetOptions } from "../../../../src/index.js"; + +export type FutureStetOptions = StetOptions & { + visible?: boolean; + animate?: boolean; + animationDuration?: number; + animationDelay?: number; +}; +export type AdapterOptions = T & { onHandle?: (handle: StetHandle | null) => void }; + +export declare function ReactCircle( + props: AdapterOptions & { target: RefObject }, +): null; +export declare const vStetCircle: Directive>; +export declare const svelteCircle: Action< + HTMLElement, + AdapterOptions | undefined +>; diff --git a/docs/delivery/contracts/fixtures/angular.ts b/docs/delivery/contracts/fixtures/angular.ts new file mode 100644 index 0000000..c3a817c --- /dev/null +++ b/docs/delivery/contracts/fixtures/angular.ts @@ -0,0 +1,24 @@ +import { Component, Directive, Input } from "@angular/core"; +import type { StetHandle } from "../../../../src/index.js"; +import type { FutureStetOptions } from "./adapter-types.js"; + +@Directive({ selector: "[stetCircle]", standalone: true }) +export class StetCircleContractDirective { + @Input() stetCircle: FutureStetOptions = {}; + @Input() stetOnHandle?: (handle: StetHandle | null) => void; +} + +@Component({ + standalone: true, + imports: [StetCircleContractDirective], + template: ``, +}) +export class Example { + annotationVisible = false; + handle: StetHandle | null = null; + readonly onStetHandle = (next: StetHandle | null) => { + this.handle = next; + }; +} diff --git a/docs/delivery/contracts/fixtures/react.tsx b/docs/delivery/contracts/fixtures/react.tsx new file mode 100644 index 0000000..272e822 --- /dev/null +++ b/docs/delivery/contracts/fixtures/react.tsx @@ -0,0 +1,17 @@ +import { useCallback, useRef } from "react"; +import type { StetHandle } from "../../../../src/index.js"; +import { ReactCircle as Circle } from "./adapter-types.js"; + +export function Example({ isVisible }: { isVisible: boolean }) { + const target = useRef(null); + const handle = useRef(null); + const onHandle = useCallback((next: StetHandle | null) => { + handle.current = next; + }, []); + return ( + <> + + + + ); +} diff --git a/docs/delivery/contracts/fixtures/svelte.svelte b/docs/delivery/contracts/fixtures/svelte.svelte new file mode 100644 index 0000000..734adcc --- /dev/null +++ b/docs/delivery/contracts/fixtures/svelte.svelte @@ -0,0 +1,10 @@ + + + diff --git a/docs/delivery/contracts/fixtures/tsconfig.json b/docs/delivery/contracts/fixtures/tsconfig.json new file mode 100644 index 0000000..669d430 --- /dev/null +++ b/docs/delivery/contracts/fixtures/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "../../../..", + "jsx": "react-jsx", + "experimentalDecorators": true + }, + "include": ["*.ts", "*.tsx", "*.vue", "*.svelte"] +} diff --git a/docs/delivery/contracts/fixtures/vue.vue b/docs/delivery/contracts/fixtures/vue.vue new file mode 100644 index 0000000..3e13adc --- /dev/null +++ b/docs/delivery/contracts/fixtures/vue.vue @@ -0,0 +1,13 @@ + + + diff --git a/docs/delivery/contracts/playwright.md b/docs/delivery/contracts/playwright.md index 6c457a6..b20bccf 100644 --- a/docs/delivery/contracts/playwright.md +++ b/docs/delivery/contracts/playwright.md @@ -12,6 +12,75 @@ and `PW-02` - Callers supply their own Playwright installation and objects. - Browser-core graphs contain no Node or Playwright code. +The entry has an optional `@playwright/test` peer used for types only; the first +release supports `>=1.63 <2`. It has no runtime dependency on Playwright and does +not require consumers to import Stet's browser core themselves. + +## Public API + +```ts +import type { Frame, Locator, Page } from "@playwright/test"; +import type { + ArrowOptions, + MarkKind, + StetAnimationResult, + StetOptions, + StickyOptions, +} from "@funsaized/stet"; + +export function createStet(context: Page | Frame): Promise; + +export interface StetSession { + circle(target: Locator, options?: StetOptions): Promise; + underline(target: Locator, options?: StetOptions): Promise; + highlight(target: Locator, options?: StetOptions): Promise; + arrow(from: Locator, to: Locator, options?: ArrowOptions): Promise; + sticky(target: Locator, options: StickyOptions): Promise; + mark(target: Locator, kind: MarkKind, options?: StetOptions): Promise; + dispose(): Promise; +} + +export interface RemoteStetHandle { + show(): Promise; + hide(): Promise; + replay(): Promise; + refresh(): Promise; + resketch(seed?: number): Promise; + destroy(): Promise; +} + +export type StetPlaywrightErrorCode = + | "UNSUPPORTED_CONTEXT" | "FRAME_DETACHED" | "TARGET_MISSING" + | "TARGET_AMBIGUOUS" | "TARGET_DETACHED" | "WRONG_DOCUMENT" + | "NAVIGATION" | "PAGE_CLOSED" | "SESSION_DISPOSED" | "LOAD_FAILED" + | "CSP_BLOCKED" | "INVALID_OPTIONS" | "UNSUPPORTED_ANIMATION" + | "BROWSER_OPERATION"; + +export class StetPlaywrightError extends Error { + readonly code: StetPlaywrightErrorCode; + readonly primitive?: "circle" | "underline" | "highlight" | "arrow" | "sticky" | "mark"; + readonly role?: "target" | "from" | "to"; +} +``` + +Remote methods are asynchronous because they cross the Playwright/browser +boundary. `show()` and `replay()` preserve the runtime's `finished`/`cancelled` +results and in-flight operation joining; normal runtime interruption does not +reject. Other methods resolve after their browser-side effect. Calls on a remotely +destroyed handle follow the runtime's destroyed-handle behavior. Calls after +session `dispose()` behave as calls on destroyed handles; repeated handle destroy +and session dispose resolve successfully. Primitive attachment methods resolve as +soon as the remote handle exists; they do not await an animated entrance. A +following `show()` joins an entrance still in flight from attachment. + +Each session starts attach, method, and disposal requests in call order, without +sharing a queue across sessions. A `show()` that joins an in-flight operation +returns the exact same Node-side Promise object (`===`), sends no second browser +command, and does not restart. `hide()`, `replay()`, `refresh()`, `resketch()`, +`destroy()`, and `dispose()` can run while a show/replay promise is awaiting so +they can interrupt it under the runtime contract. Navigation is not an annotation +operation. + ## Behavior - Create an owned annotation session for a `Page` or same-origin `Frame`. @@ -20,15 +89,108 @@ and `PW-02` - Expose asynchronous remote handles matching the runtime result semantics. - Multiple sessions and source-authored Stet annotations coexist. - Cleanup removes only resources owned by that session. -- Navigation invalidates the document and cancels pending work. +- Navigation, frame detachment, or page/context loss rejects affected pending + helper calls, invalidates the session, and is never runtime `cancelled`. - Do not automatically reinject, rediscover targets, or cross documents. - Cross-document arrows and cross-origin frames are unsupported initially. - Do not silently enable CSP bypass. +`createStet(page)` binds to the page's current main-frame document; +`createStet(frame)` binds to that frame's current document. Frame support requires +the frame document to be same-origin with its page's main-frame document, +including inherited-origin `about:blank`. Detached frames, workers, browser +contexts, element handles, and cross-origin frames are rejected. Navigating the +bound page/frame invalidates the session permanently; callers create a new session +explicitly for the new document. There is no automatic reinjection. + +An attachment call takes a non-waiting snapshot of current locator matches using +`count()` or an equivalent operation: no action timeout, Stet retry, or live +retargeting follows that snapshot. For each locator, the first failing check wins: +wrong frame/document is `WRONG_DOCUMENT`; zero matches is `TARGET_MISSING`; more +than one is `TARGET_AMBIGUOUS`; and the one selected Element detaching before +attachment finishes is `TARGET_DETACHED`. It must be a connected Element in the +bound document at attachment. Arrows snapshot `from` and then `to`, apply the same +ordered checks with those roles, and do not attach if either fails. Later target +replacement or detachment never retargets the annotation; runtime culling applies +until explicit handle/session cleanup or document invalidation. + +The helper assigns an opaque owner per session and tracks only that owner's remote +handles. Destroying a handle removes only that handle. `dispose()` destroys all +remaining owned handles. Loader-resource lifetime remains evidence-gated, but +cleanup may not remove resources or annotations still owned by another helper +session, and source-authored Stet overlays/descriptions are never adopted or +removed. A failed creation or attachment rolls back mutations made for that +attempt. + +Navigation, frame detachment, page closure, or browser-context loss rejects an +affected in-flight helper call; it is not reported as runtime `{ status: +"cancelled" }`. The invalidated session rejects later attachment/handle method +calls, except idempotent `dispose()` which resolves. If the browser realm remains +available and `hide()`, `replay()`, `destroy()`, or `dispose()` interrupts a reveal, +the runtime cancellation result remains normal. + +After `dispose()`, session primitive methods reject `SESSION_DISPOSED` without +resolving locators or mutating the page. Existing remote handles retain the +destroyed-handle behavior above. + Missing, ambiguous, detached, wrong-document, navigation, loading, and CSP failures must be distinguishable and actionable. Normal runtime animation cancellation is not an exceptional browser error. +## Errors + +All helper failures use `StetPlaywrightError`, retain a cause when available, and +have one stable `code`: + +| Code | Meaning | +| --- | --- | +| `UNSUPPORTED_CONTEXT` | Input is not a supported Page/Frame or a frame is cross-origin. | +| `FRAME_DETACHED` | The bound frame detached before the requested operation completed. | +| `TARGET_MISSING` | A locator matched no element at its single resolution point. | +| `TARGET_AMBIGUOUS` | A locator matched more than one element. | +| `TARGET_DETACHED` | The selected element detached before attachment completed. | +| `WRONG_DOCUMENT` | A locator/element belongs to a document other than the session document, including either endpoint of an arrow. | +| `NAVIGATION` | The bound document changed during or before an operation. | +| `PAGE_CLOSED` | The page/browser context became unavailable without a usable document. | +| `SESSION_DISPOSED` | A primitive attachment was requested after session disposal. | +| `LOAD_FAILED` | Package-local renderer or stylesheet resources could not be loaded for a reason not classified more specifically. | +| `CSP_BLOCKED` | The evidence-selected loader detected that page policy blocked a required resource or execution step. No bypass is enabled. | +| `INVALID_OPTIONS` | Runtime option validation failed. | +| `UNSUPPORTED_ANIMATION` | Animation was requested for a primitive without first-release reveal support. | +| `BROWSER_OPERATION` | Another Playwright/browser evaluation failed; the message identifies the operation and preserves its cause. | + +Target errors identify the primitive and locator role (`target`, `from`, or `to`) +without serializing page content. Loading and CSP errors include a recovery hint +derived from PW-01/PW-02 evidence; the contract does not prescribe that hint or +claim all browser policy failures can be distinguished until those gates pass. +Runtime `RangeError`/validation `TypeError` maps to `INVALID_OPTIONS`; the specific +unsupported-reveal `TypeError` maps to `UNSUPPORTED_ANIMATION`, preserving causes. +`BROWSER_OPERATION` is residual and cannot replace a more specific code. + +## Acceptance matrix + +| Case | Required result | +| --- | --- | +| Page main document / same-origin Frame | Session creation and all six primitive methods are available. | +| Object is not a live Page/Frame, including worker, context, ElementHandle, Locator, or FrameLocator | Reject `UNSUPPORTED_CONTEXT`; no mutation. | +| Cross-origin/sandboxed-unique-origin Frame, or Frame detached at creation | Reject `UNSUPPORTED_CONTEXT`; no mutation. | +| Bound Frame later detaches | Reject `FRAME_DETACHED` and invalidate the session. | +| Page/context is already closed or later closes without a usable document | Reject `PAGE_CLOSED`; no mutation or further use. | +| One live locator target | Attach once and return an owned remote handle. | +| Zero / multiple / race-detached / other-document target | Reject with the corresponding target code and roll back that attempt. | +| Arrow endpoints in different documents | Reject `WRONG_DOCUMENT`; cross-document arrows are not attempted. | +| Runtime finish / hide, replay, destroy cancellation | Resolve `finished` or `cancelled` exactly as the runtime contract specifies. | +| Attach starts a reveal | Attachment resolves with the handle before reveal completion; `show()` can join it. | +| In-flight remote show plus another show | Return the same Node-side promise and settle once with the runtime result. | +| In-flight show plus hide/destroy/dispose while browser lives | Interrupt immediately and resolve the reveal `cancelled`, not a browser error. | +| Navigation, frame detachment, or page closure during a call | Reject with browser-lifecycle code, invalidate the session, and never translate it to normal cancellation. | +| Multiple helper sessions plus source-authored Stet | Independent handles and cleanup; disposing one leaves all foreign annotations/resources intact. | +| Repeated destroy/dispose | Resolve without affecting foreign ownership. | +| Primitive attachment after dispose | Reject `SESSION_DISPOSED` immediately. | +| Missing locator while Playwright default timeout is armed | Reject `TARGET_MISSING` from the non-waiting snapshot rather than waiting for an action timeout. | +| Restrictive CSP or package-resource load failure | Succeed only where PW evidence proves support; otherwise reject actionable `CSP_BLOCKED`/`LOAD_FAILED`, with rollback and no bypass. | +| Consumer package | Subpath types/runtime/assets resolve from a packed package without consumer esbuild; browser core remains free of Node/Playwright modules. | + ## Evidence gates `PW-01` must prove renderer and stylesheet loading, restrictive-CSP behavior, @@ -39,4 +201,8 @@ first-release support gate unless that run is made mandatory before release. `PW-02` must prove packed payload discovery, no consumer bundler dependency, navigation cancellation, cleanup ownership, and browser-core isolation. -No worker may invent the final loader before both gates are accepted. +The exact renderer transport, stylesheet transport, resource lifetime, and the +boundary between detectable CSP and generic load failures remain exclusively for +PW-01/PW-02 evidence. No worker may invent or document the final loader before +both gates are accepted; all public signatures and observable outcomes above are +independent of that mechanism. diff --git a/docs/delivery/contracts/runtime.md b/docs/delivery/contracts/runtime.md index 900918c..9cca5da 100644 --- a/docs/delivery/contracts/runtime.md +++ b/docs/delivery/contracts/runtime.md @@ -23,6 +23,10 @@ animationDelay?: number; - Unsupported animation fails before DOM or ARIA mutation. - Easing, exit animation, reveal direction, and timelines are out of scope. +Option validation is synchronous during attachment. A non-finite or negative +duration/delay throws `RangeError`; requesting enabled animation on an unsupported +primitive throws `TypeError`. Cancellation is a normal result, not a rejection. + The options remain flat because the current schema generator supports scalars and literal unions, not nested objects. @@ -43,24 +47,50 @@ interface StetHandle { ## State rules -| Event | Result | -| --- | --- | -| Static attach | Visible and settled | -| Animated attach | One entrance | -| Hidden attach | No entrance until shown | -| Show during entrance | Same in-flight promise; no restart | -| Show while settled-visible | Immediate `finished` | -| Show after hide | New entrance if enabled | -| Replay | Visible, restart; prior operation becomes `cancelled` | -| Hide during entrance | Immediate hide; pending result `cancelled` | -| Destroy during entrance | Cleanup; pending result `cancelled` | -| Reduced motion during entrance | Final state; result `finished` | -| Calls after destroy | show/replay return `cancelled`; others are harmless | +An operation is the delay plus reveal started by animated attach, `show()`, or +`replay()`. At most one operation exists per handle. Every operation promise +settles exactly once and never rejects for normal lifecycle interruption. + +| Current state | Event | Result | +| --- | --- | --- | +| Attaching, `visible` omitted/true, animation disabled | Attach | Visible and settled; no operation is created. Existing static DOM and drawing behavior is unchanged. | +| Attaching, `visible` omitted/true, animation enabled | Attach | Visible; start exactly one entrance operation. | +| Attaching, `visible: false` | Attach | Explicitly hidden and settled; do not start an operation or expose owned ARIA relationships, even when animation is enabled, duration is zero, or reduced motion is active. | +| Delaying or revealing | `show()` | Return the exact same promise object (`===`); do not restart or extend the operation. This also applies when the operation came from attach or `replay()`. | +| Settled and visible | `show()` | Do not redraw or replay; return a newly created already-fulfilled `finished` promise. Promise identity is specified only while joining an operation. | +| Settled and explicitly hidden | `show()` | Always make visible and restore owned ARIA. Start a new operation when animation is enabled; otherwise return a newly created already-fulfilled `finished` promise. | +| Any non-destroyed state | `replay()` | Make visible, restore owned ARIA, and preserve the current seed. Cancel any prior operation, then start a distinct entrance operation when animation is enabled; otherwise settle visibly and return a newly created already-fulfilled `finished` promise. | +| Delaying or revealing | `replay()` | Settle the old promise as `cancelled` before the new operation can settle. A following `show()` joins the new replay promise. | +| Any non-destroyed state | `hide()` | Immediately set explicit hidden state and detach owned ARIA. If an operation exists, stop it and settle it `cancelled`. Repeated hides are no-ops. | +| Delaying or revealing | Reduced motion becomes active | Skip delay and remaining reveal, render the final visible state, and settle the existing operation `finished`. | +| Reduced motion active or zero duration | Animated attach that is not explicitly hidden, `show()`, or `replay()` | Reach the final visible state without waiting for delay and create no operation. Attach returns only the handle; `show()`/`replay()` (including from explicit hide) restore visibility/ARIA and return a newly created already-fulfilled `finished` promise. A later `show()` follows the settled-visible row. Initial `visible: false` attach still follows the hidden-attach row. | +| Any non-destroyed state | `destroy()` | Stop and settle any operation `cancelled` before teardown, detach owned ARIA, remove all Stet-owned DOM/listeners/observers, and enter destroyed state. Repeated destroy is a no-op. | +| Destroyed | `show()` or `replay()` | Return a newly created already-fulfilled `cancelled` promise. | +| Destroyed | `hide()`, `refresh()`, `resketch()`, or `destroy()` | No-op and do not throw. | + +“Already fulfilled” means normal Promise microtask observation; these methods do +not synchronously return a result object. Once an operation is cancelled, late +timers, animation events, media changes, or redraws cannot change its result. + +Explicit hidden state is cleared only by `show()` or `replay()` (or discarded by +`destroy()`). `refresh()`, `resketch()`, resize/intersection observers, scrolling, +fonts, theme changes, and viewport culling cannot make an explicitly hidden +annotation visible or restore its ARIA relationships. `refresh`, `resketch`, layout observation, scrolling, fonts, and theme changes do not replay. Viewport culling neither pauses nor starts animation. Offscreen animation may finish offscreen; viewport-triggered reveal is a separate recipe. +During an operation, `refresh()` preserves the current seed and `resketch()` uses +the requested/new seed. Both redraw at the operation's current progress without +changing its start time, deadline, or promise. Replacing drawn nodes must not +restart the delay or reveal. Ambient `boil` and hover resketching follow the same +rule. A target that is offscreen, has no client rectangles, or is temporarily +detached is culled but does not pause or cancel an operation. Reconnection exposes +the current or final frame. For multi-target annotations, culling either endpoint +culls the whole annotation. Target replacement is an adapter update, not DOM +detachment; it follows the update rule below. + ## Accessibility ownership - Explicit hide detaches every Stet-owned `aria-describedby` relationship. @@ -70,6 +100,21 @@ animation may finish offscreen; viewport-triggered reveal is a separate recipe. - Existing native and other annotation IDs are preserved. - This includes mount descriptions, arrow labels, and sticky text. +Initial `visible: false` follows the explicit-hide rule. Descriptive nodes may stay +in Stet's overlay while hidden, but their IDs are absent from target +`aria-describedby` values. Restoring visibility appends each missing owned ID once +without removing, reordering, or duplicating foreign IDs. If application code +changes `aria-describedby` while Stet is hidden or visible, later hide/destroy +removes only IDs owned by that handle. Viewport/detachment culling is not explicit +hiding and therefore preserves relationships. + +`arrow` and `sticky` return the complete handle without dropping methods. Arrow +labels describe the destination target; sticky text describes its target. Their +label/text relationships obey the same initial hide, hide, show, replay, and +destroy rules as `description` on mount. Animation options on these unsupported +first-release primitives fail during attachment as stated above; their static +handles still support immediate show, hide, and replay. + ## Updates The first release needs an internal nonreplaying update path, not a public generic @@ -77,3 +122,20 @@ The first release needs an internal nonreplaying update path, not a public gener and do not start a fresh entrance. Animation settings affect the next entrance. Target replacement cancels the old handle and applies normal initial attachment to the new target. + +## Executable test map + +| Contract behavior | Required check | +| --- | --- | +| Option defaults, validation, unsupported reveal, and no pre-error DOM/ARIA mutation | Unit table in `tests/stet.test.ts` for omitted/explicit options, negative/non-finite timing, and every unsupported primitive. | +| Static compatibility and immediate static show/replay | Unit assertions for unchanged paths/DOM, no document animations, static hide then show restoring visibility/ARIA, settled-visible show returning a distinct new `finished` promise without redraw, and fulfilled replay preserving the seed; retain current browser static screenshots. | +| Animated attach, hidden attach, show joining, and show after hide | Fake-time unit state table asserting operation count, exact in-flight promise identity, results, visibility, and ARIA; hidden attach wins over zero duration/reduced motion and instant-finish attach leaves no joinable operation. | +| Replay while settled, hidden, delaying, and revealing | Fake-time unit state table asserting the old result is `cancelled`, the new promise is distinct, and a subsequent show joins it. | +| Hide/destroy interruption and all post-destroy calls | Fake-time unit checks proving immediate visibility/cleanup, exactly-once cancellation, harmless void calls, and no late `finished`. | +| Zero duration and reduced motion initially/mid-operation | Unit media-change checks plus a browser preference-change case proving immediate final state and `finished`. | +| Refresh, resketch, resize, scroll, fonts, theme, boil, and hover during reveal | Unit fake-time checks for unchanged operation identity/deadline and updated geometry; browser check for no replay or variant flash. | +| Offscreen, detached/reconnected, and multi-target culling | Browser cases extending `tests/browser/scroll.spec.ts`; operation completes while culled and ARIA remains unless explicitly hidden. Existing overflow-clipping behavior is unchanged and is not promoted to a new culling rule. | +| Explicit hide versus observer visibility | Unit/browser check that hide survives intersection, resize, scroll, fonts, theme, refresh, and resketch until `show()` or `replay()`. | +| Description ownership through hide/show/replay/destroy | Unit checks extending the existing independent/later-added ARIA test for mount descriptions, arrow labels, and sticky text. | +| Arrow/sticky wrapper completeness | Compile-time/runtime unit check that both wrappers expose every `StetHandle` method and preserve their target-specific ARIA ownership. | +| Adapter target replacement | Adapter lifecycle tests proving old-operation cancellation and normal initialization on the replacement target; detailed adapter behavior is in `adapters.md`. | diff --git a/docs/delivery/contracts/shipping.md b/docs/delivery/contracts/shipping.md index dc84a08..fff21e6 100644 --- a/docs/delivery/contracts/shipping.md +++ b/docs/delivery/contracts/shipping.md @@ -4,12 +4,13 @@ Status: approved for first-release implementation ## Modes -| Mode | Result | -| --- | --- | -| Production | Intentional annotations ship | -| Preview-only | Review annotations ship only to previews | -| Local-only | Review annotations are available only in development | -| Mixed | Intentional production annotations remain; review annotations do not | +| Mode | Module ownership and selection | Customer production output | +| --- | --- | --- | +| Production | Intentional annotations live in `stet/production.*` and are imported normally; review policy is `off`. | Production annotation code/copy and required Stet runtime/CSS remain. | +| Preview-only | All annotation code, copy, plans, and Stet/CSS imports live under `stet/review.*`; policy is `preview`. | Review module/content and otherwise-unused Stet runtime/CSS are absent. A build using the explicit preview mode includes and renders them. | +| Local-only | Same review ownership; policy is `local`. | Every emitted build excludes review content/runtime. The development server includes and renders it. | +| Disabled | No production boundary is imported and review policy is `off`; callers do not invoke retained annotation setup. | No annotation renders. Exclusion may also hold, but only the disabled guarantee is claimed unless output checks prove more. | +| Mixed | Intentional annotations stay in `stet/production.*`; temporary annotations stay in `stet/review.*` with `preview` or `local` policy. | Production annotations/runtime/CSS remain; temporary review module/copy/plans/review-only CSS do not. | ## Guarantees @@ -21,11 +22,120 @@ The first mechanism is an explicit review-only module/component boundary selecte at build time. It relies on normal bundling, not arbitrary JSX, Vue, Svelte, or Angular rewriting. +## Explicit Vite boundary + +The consumer owns three source boundaries (extensions/framework components may +vary): + +- `src/stet/production.*` contains only intentional shipping annotations and is + imported directly by application code when the production or mixed mode needs + it. +- `src/stet/review.*` owns every temporary annotation import, stylesheet import, + string, plan/JSON import, setup function, and framework component. +- `src/stet/review-boundary.ts` is the only application import of `review.*`. It + conditionally performs `import("./review")` behind the compile-time boolean + `__STET_REVIEW_BUILD__` and otherwise exposes a no-op with the same host-facing + signature. + +The review module is invoked only from a client lifecycle after targets exist; it +has no top-level DOM work. SSR may import the boundary, but a false boundary never +loads `review.*`, and the server render does not attach annotations. + +Vite config owns a literal policy (`"off" | "preview" | "local"`) in source and +defines `__STET_REVIEW_BUILD__` to the JSON boolean result: + +| Policy | `vite serve` | ordinary/customer `vite build` | `vite build --mode stet-preview` | +| --- | --- | --- | --- | +| `off` | `false` | `false` | `false` | +| `preview` | `false` | `false` | `true` | +| `local` | `true` | `false` | `false` | + +Unknown policies fail configuration. `stet-preview` is an explicit build target, +not a client-readable `VITE_*` runtime flag. Deployment may select that command, +but an environment variable alone is not the shipping boundary. The constant must +be replaced with literal `true` or `false`; reading it dynamically or defining it +to a string is unsupported. + +The boundary controls temporary code inclusion, not intentional annotations. +Production imports never pass through it. It does not rewrite component syntax or +turn existing calls into no-ops. + Output tests inspect emitted JavaScript, CSS, assets, reachable chunks, and published source maps. Mixed builds must retain production annotations. A no-op adapter does not prove content exclusion. The website's checkout aliases cannot be used to prove packed-package behavior. +## Falsifiable output matrix + +Each fixture gives production code/copy, review code/copy/plan, and review-only CSS +distinct sentinel strings. Tests build from a packed `@funsaized/stet` package, +set `build.manifest: true`, list every emitted file, and inspect all output plus +Vite's manifest/dynamic-import graph; checking only the entry chunk is +insufficient. + +| Build | Browser assertion | Emitted JS/CSS/assets | Published source maps | +| --- | --- | --- | --- | +| Production | Intentional overlay renders and native behavior works; no review overlay. | Production sentinels and required Stet runtime/CSS present; all review sentinels/module/chunks/assets absent. | Production sources may remain; excluded review-module needles defined below are absent. | +| Preview-only preview | Review overlay renders and native behavior works. | Review module, copy/plan sentinel, review CSS, Stet runtime, and required Stet CSS present. | Review sources/content may remain. | +| Preview-only customer | No annotation renders; native behavior works. | Review module/chunks/copy/plan/CSS sentinels absent; Stet JS/CSS absent when there is no other Stet import. | No review-module `sources` entry or review sentinel in decoded map fields; the boundary may remain. | +| Local-only development | Review overlay renders in the served development module graph; native behavior works. | No production artifact claim; request/module-graph evidence proves the review module is served only in development. | Not applicable unless the dev server emits a map; any retained map is recorded, not published. | +| Local-only build | No annotation renders; native behavior works. | Same exclusions as preview-only customer for every build mode. | No review-module `sources` entry or review sentinel in decoded map fields; the boundary may remain. | +| Disabled | No annotation renders and native behavior works. | Code may remain; absence is not inferred from the runtime assertion. | No exclusion claim unless the sentinel scan also passes. | +| Mixed preview | Production and review overlays render; native behavior works. | Both sets of sentinels/modules and required Stet runtime/CSS are present. | Both source sets may remain. | +| Mixed local development | Production and review overlays render in the served graph; native behavior works. | No production-artifact claim; module-graph evidence includes both boundaries. | Not applicable unless the dev server emits a map. | +| Mixed customer | Intentional overlay renders; review overlay does not; native behavior works. | Production sentinels/runtime/CSS present; review module/chunks/copy/plan/CSS sentinels absent. | Production and boundary sources may remain; no review-module `sources` entry or review sentinel appears in decoded map fields. | + +SSR/prerender fixtures apply the same matrix to both client and server output. +Customer builds scan the SSR bundle, client bundle, manifests, maps, assets, and +prerendered HTML for excluded needles; hydration renders no review overlay and +preserves native behavior. Preview builds may emit the review module in the client +and server graphs, but server rendering executes no annotation setup and +prerendered HTML contains no Stet-owned overlay; the review overlay appears only +after client hydration. Mixed SSR customer builds retain production-module +sentinels while excluding review needles. SSR is unsupported for a configuration +that cannot pass these equivalent output and hydration checks. + +Output fixtures must run excluded builds with `build.sourcemap: "hidden"` and with +at least one of `true` or `"inline"`; `false` may be recorded additionally. Tests +discover external maps by listing output rather than relying on +`sourceMappingURL`, and decode inline map payloads. The always-imported +`review-boundary.ts` may appear in maps. Exclusion requires that no map `sources` +entry resolves to `stet/review.*`, and no decoded `sourcesContent`, `names`, or +inline payload contains a review sentinel. Raw `mappings` need not be empty. +Manifests must have no review entry or reachable review chunk in excluded builds. +Plan/content files copied independently through `publicDir`, static-copy plugins, +or deployment steps are outside tree-shaking and fail content exclusion if they +contain a review sentinel. + +The three guarantees are accepted independently: + +1. Disabled requires a browser test proving zero Stet-owned overlays/ARIA changes + while the same native interaction passes. +2. Runtime excluded is claimed only for preview-only customer, local-only build, + or another configuration with no retained Stet import. It requires that no + emitted JavaScript, CSS, or decoded map + contains Stet runtime/style needles (`stet-overlay`, `stet-description-`, or + `--stet-stroke`); the manifest/dynamic-import graph has no review chunk or Stet + CSS asset; and consumer source has no Stet/CSS import outside the declared + production/review boundaries. +3. Content excluded requires review code/copy/plan/CSS sentinels absent from all + artifacts and manifests, plus no review-module `sources` entry or review + sentinel in decoded map fields. `review-boundary.ts` and its import specifier may + remain. + +## Unsupported configurations + +- Temporary strings, plans, CSS, or Stet imports outside `review.*`. +- Static/top-level imports of `review.*`, including from production components or + an SSR entry. +- Runtime-only environment checks, no-op adapters, or `visible: false` presented as + runtime/content exclusion. +- Universal textual/AST stripping, automatic framework-source rewriting, and an + unproven Vite plugin. +- Assuming another bundler, minifier, source-map processor, static-copy plugin, or + deployment pipeline preserves these guarantees without equivalent output tests. +- CDN loading or website source aliases as package-consumer evidence. + A Vite plugin is a post-release, evidence-gated convenience. It is retained only if the plain boundary causes repeated setup errors and it produces the same tested output. diff --git a/docs/delivery/evidence/BASE-01.md b/docs/delivery/evidence/BASE-01.md new file mode 100644 index 0000000..978116b --- /dev/null +++ b/docs/delivery/evidence/BASE-01.md @@ -0,0 +1,25 @@ +# BASE-01 evidence + +- Revision/base: `7279869d68c09829de08b36cd74633e9dd63c0db` on branch `product`. The working tree was clean before task-state and evidence edits. +- Environment: Linux 7.2.3-arch1-3 x86_64; Node `v26.7.0`; npm `11.19.0`; Python `3.14.7`; root Playwright `1.63.0`. The matching Playwright cache contains Chromium 1243, Firefox 1543, and WebKit 2359. +- Files changed: `docs/delivery/tasks/BASE-01.md`, `docs/delivery/backlog.md`, and this evidence file only. +- Acceptance criteria checked: the revision and initial tree state are recorded; root unit/type/generated-contract/package/browser/pattern checks and website checks establish an attributable baseline; every failure and unrun check is listed separately; browser and payload availability are explicit. +- Commands and exit status: + - `git status --short && git diff --stat` — exit 0; no output before task edits (clean tree). + - `git rev-parse HEAD`, `git branch --show-current`, `node --version`, `npm --version`, `python --version`, `uname -a` — exit 0; values recorded above. + - `node_modules/.bin/playwright install --list` — exit 0; matching 1.63.0 Chromium, Firefox, and WebKit installations listed above. It also reported unrelated Playwright 1.62.1 cache entries. + - `npm test` — exit 0; the Vitest reporter recorded 14 files and 99 tests passed. + - `npm run check` — exit 0; TypeScript, Angular, and generated-agent drift checks passed. + - `npm run test:agent` — exit 0; 9 files and 60 tests passed. + - First `npm run test:templates` — exit 1 while other build commands were running concurrently; Angular reported NG2012 unknown-import errors for six directive snippets. Isolated retry — exit 0; all 35 snippets and lifecycle patterns typechecked, including Angular and Vue/Svelte SFCs. The first result was command interference, not retained as a product failure. + - `npm run test:package` — exit 0; 681,963-byte archive, 1,261,695 unpacked bytes, 246 files, zero consumer runtime dependencies, 3,387-byte minimal-circle gzip, and 4,986-byte full-surface gzip. + - `npm run test:cli-consumer` — exit 1 before consumer setup: the script requires a tarball argument (`AssertionError: Pass an actual Stet tarball`). No current-version tarball was created because BASE-01 forbids changing artifacts for the check. + - `npm run size` — exit 0; core 6.83 KiB gzip and CSS 1.11 KiB gzip, within the current 7 KiB and 3 KiB limits. + - `npm run test:browser` — exit 0; 28 tests passed in Chromium and Firefox. + - `npm run test:patterns` — exit 0; 18 tests passed in Chromium and Firefox, including framework SSR and lifecycle fixtures. + - `npm --prefix website run check` — exit 0; lint, format check, TypeScript, client/SSR builds, prerender of 16 public pages, and SEO integrity passed. + - `npm --prefix website test` — exit 1; 84 passed and four local Chromium screenshots differed from committed Linux/CI baselines: desktop workspace 1,053 pixels, desktop security handoff 1,045, desktop form review 1,064, and mobile workspace 2. Functional tests passed; this is a local renderer/baseline mismatch, not a product behavior failure. +- Browser/manual artifacts: automated browser checks ran locally. Failure screenshots, diffs, error contexts, and traces remain in ignored `website/test-results/`; no manual visual approval was required or claimed. +- Pre-existing failures: local website screenshot comparisons differ from the committed Ubuntu/CI baselines as detailed above. No functional root or website failure remained after the isolated template retry. The CLI consumer command's missing required tarball is an invocation prerequisite, not a product result. +- Unverified items and reason: cached WebKit was not launched because existing local scripts include it only when `STET_WEBKIT=1`; CI's Ubuntu 24.04/Node 22 jobs, WebKit-with-system-dependencies path, Windows and Node 20.0.0/24 CLI matrix, and release workflow were not reproduced on this Arch Linux/Node 26 host; website screenshots were not reproduced on the CI image; demo and trial suites are outside the baseline command set; current-version CLI fresh-consumer behavior is unverified because no tarball argument was available and creating one was outside this task. +- Reviewer decision: ACCEPT; independent review confirmed the corrected evidence satisfies BASE-01. diff --git a/docs/delivery/evidence/CONTRACT-01.md b/docs/delivery/evidence/CONTRACT-01.md new file mode 100644 index 0000000..8b17e97 --- /dev/null +++ b/docs/delivery/evidence/CONTRACT-01.md @@ -0,0 +1,11 @@ +# CONTRACT-01 evidence + +- Revision/base: `7279869d68c09829de08b36cd74633e9dd63c0db`; BASE-01 is ACCEPTED. +- Files changed: `docs/delivery/contracts/runtime.md`, task/backlog state, and this evidence file; no product or generated files. +- Implementation inspected: `src/mount.ts`, `src/primitives.ts`, all four framework adapters, `style.css`, runtime/browser tests, direct/template/site `StetHandle` callers, package scripts, and agent generator/catalog/template machinery. +- Acceptance criteria checked: the runtime contract now fixes exact in-flight promise identity, overlap and cancellation ordering, static and destroyed behavior, redraw progress, target culling/detachment, ARIA ownership, unsupported animation validation, complete arrow/sticky handles, and a runnable test location for every transition. +- Caller review: direct vanilla/site/template callers consume cleanup and redraw methods; React, Vue, Svelte, and Angular currently destroy/remount handles; arrow and sticky currently wrap and narrow the returned handle. The contract explicitly requires wrapper completeness and gives adapters a target-replacement rule without prescribing CONTRACT-02 ergonomics. +- Commands and actual outcomes: repository reads and searches only; `StetHandle` search found runtime, primitive wrappers, four adapters, unit tests, templates/patterns, website, docs, and retained trial/evidence consumers. No product test was rerun because this task changes documentation only; BASE-01 contains the current executed baseline. +- Generated-contract review: flat scalar options match `scripts/agent/generate.mjs`; lifecycle metadata/templates currently describe only `refresh`, `resketch`, and `destroy`, so implementation tasks must regenerate rather than hand-edit outputs. +- Unverified items: the transition test map is design evidence, not an assertion that unimplemented lifecycle tests pass. +- Reviewer decision: ACCEPT after three review passes resolved instant-finish, replay, observer visibility, destroy settlement, seed, and test-map ambiguities. diff --git a/docs/delivery/evidence/CONTRACT-02.md b/docs/delivery/evidence/CONTRACT-02.md new file mode 100644 index 0000000..968a629 --- /dev/null +++ b/docs/delivery/evidence/CONTRACT-02.md @@ -0,0 +1,16 @@ +# CONTRACT-02 evidence + +- Revision/base: `7279869d68c09829de08b36cd74633e9dd63c0db`; CONTRACT-01 is ACCEPTED. +- Files changed: `docs/delivery/contracts/adapters.md`, compile-only fixtures under `docs/delivery/contracts/fixtures/`, task/backlog state, and this evidence file; no product/generated files. +- Implementation inspected: all four source adapters, mount/primitives handle flow, adapter unit tests, generated lifecycle patterns/templates, package scripts, and TypeScript/Angular/Vue/Svelte compiler configuration. +- Acceptance criteria checked: exact React, Vue, Svelte, and Angular callback syntax is documented and represented by compile-only fixtures; callbacks are stripped from runtime options and all serialization; declarative visibility, imperative state, updates, callback transfer, teardown, and target replacement have one ordered algorithm; `updateHandle` is source-only and uses a per-handle private capability rather than a public API or registry. +- Commands and actual outcomes: + - First parallel fixture compilation exposed fixture-only setup issues: inherited `rootDir`, missing proposed visibility fields, and an unexported Angular fixture directive. These were corrected without product edits. + - `node_modules/.bin/tsc -p docs/delivery/contracts/fixtures/tsconfig.json` — exit 0. + - `node_modules/.bin/vue-tsc -p docs/delivery/contracts/fixtures/tsconfig.json --noEmit` — exit 0. + - `node_modules/.bin/svelte-check --workspace docs/delivery/contracts/fixtures --tsconfig ./tsconfig.json` — exit 0; zero errors and warnings. + - `node_modules/.bin/ngc -p docs/delivery/contracts/fixtures/tsconfig.json` — exit 0. +- Fixture boundary: fixtures model the approved future adapter types using the current `StetHandle`/`StetOptions` base; they prove framework syntax and type expressibility, not that unimplemented production adapters already expose the contract. +- Generated-contract result: `onHandle`/`stetOnHandle` are adapter metadata outside `StetOptions`, so current scalar schema generation has no callback to serialize. +- Unverified items: runtime implementation and lifecycle tests are intentionally deferred to CORE/AD tasks. +- Reviewer decision: ACCEPT after three review passes resolved callback stability, shared update ordering, internal wiring, and missing/changed target states. diff --git a/docs/delivery/evidence/CONTRACT-03.md b/docs/delivery/evidence/CONTRACT-03.md new file mode 100644 index 0000000..d7a4b2a --- /dev/null +++ b/docs/delivery/evidence/CONTRACT-03.md @@ -0,0 +1,11 @@ +# CONTRACT-03 evidence + +- Revision/base: `7279869d68c09829de08b36cd74633e9dd63c0db`; CONTRACT-01 is ACCEPTED. +- Files changed: `docs/delivery/contracts/playwright.md`, task/backlog state, and this evidence file; no product, package, or generated files. +- Implementation/package inspection: root exports and TypeScript build, package export/peer/side-effect declarations, packed-consumer assertions, Playwright 1.63 configs and Chromium/Firefox/WebKit gating, browser fixtures, workflows, and PW-01 through PW-08 task boundaries. +- Acceptance criteria checked: `createStet(Page | Frame)`, session primitives, asynchronous complete remote handles, exact Node/runtime cancellation distinction, non-waiting target snapshot/races, same-document and same-origin-frame rules, permanent navigation invalidation, disposal/rollback ownership, stable public error codes, and unsupported contexts are explicit. +- Package boundary: the contract adds an optional type-only `@playwright/test >=1.63 <2` peer and a supported package subpath; consumer esbuild, CDN assets, core Playwright/Node imports, automatic reinjection, cross-document arrows, and silent CSP bypass are excluded. +- Commands and actual outcomes: documentation/source/config inspection and repository searches only. No implementation check was run because the entry does not yet exist; BASE-01 records current Playwright/browser/package checks. +- Evidence-gated detail: exact renderer transport, stylesheet transport, loader-resource lifetime, and CSP-vs-load detection remain exclusively for PW-01/PW-02 evidence. This contract specifies outcomes without choosing `addScriptTag`, `addStyleTag`, bundling, inline content, or another mechanism. +- Unverified items: all helper runtime/package behavior remains implementation evidence for PW tasks; no CSP support claim is made. +- Reviewer decision: ACCEPT after independent review confirmed the API/error/ownership contract and that loader mechanics remain PW-01/PW-02-gated. diff --git a/docs/delivery/evidence/CONTRACT-04.md b/docs/delivery/evidence/CONTRACT-04.md new file mode 100644 index 0000000..0a931ca --- /dev/null +++ b/docs/delivery/evidence/CONTRACT-04.md @@ -0,0 +1,11 @@ +# CONTRACT-04 evidence + +- Revision/base: `7279869d68c09829de08b36cd74633e9dd63c0db`; BASE-01 and CONTRACT-02 are ACCEPTED. +- Files changed: `docs/delivery/contracts/shipping.md`, task/backlog state, and this evidence file; no product/build implementation. +- Implementation/package inspection: root and website package metadata, `sideEffects`, TypeScript/Vite configs, SSR entry, package test/bundle checks, shipping task packets, and current source usage of build/runtime environment flags. +- Acceptance criteria checked: production, preview-only, local-only, disabled, and mixed modes name exact module owners, policy/command selection, literal build constant, browser outcome, emitted JS/CSS/assets/manifest outcome, and external/hidden/inline/no-source-map treatment. Production imports remain outside the review boundary in mixed mode. +- Falsifiability: unique production/review code/copy/plan/CSS sentinels are scanned in every emitted artifact, manifest/module graph, and published source map; disabled behavior, runtime exclusion, and content exclusion have separate acceptance checks. +- Unsupported scope: runtime-only flags, no-op/hidden annotations as exclusion proof, scattered temporary content, static review imports, copied public assets, universal AST/text stripping, unproven plugins/other bundlers, CDN loading, and website aliases are not claimed. +- Commands and actual outcomes: documentation/config/package inspection and searches only. No fixture build was run because SHIP-01 creates the bounded Vite fixture and SHIP-02 proves packed output; BASE-01 records current package/website builds. +- Unverified items: emitted-output guarantees remain proposed until SHIP-01/SHIP-02 evidence runs them against the packed package and selected source-map settings. +- Reviewer decision: ACCEPT after independent review resolved source-map needles, required map modes, mixed/SSR coverage, and mode-qualified runtime exclusion. diff --git a/docs/delivery/evidence/CONTRACT-05.md b/docs/delivery/evidence/CONTRACT-05.md new file mode 100644 index 0000000..e8645a1 --- /dev/null +++ b/docs/delivery/evidence/CONTRACT-05.md @@ -0,0 +1,12 @@ +# CONTRACT-05 evidence + +- Revision/base: `7279869d68c09829de08b36cd74633e9dd63c0db`; CONTRACT-01 through CONTRACT-04 are ACCEPTED. +- Scope inspected: all delivery contracts, the complete required-task graph, optional/human-led headers and references, product-direction must-ship gates, package/generator coupling, shared runtime/website files, and current repository layout. +- Graph result: required work is acyclic. Release implementation and verification ownership, generated-output writer order, exclusive shared-file rules, critical-path convergence, and explicit human decisions are recorded in `backlog.md`. +- Corrections made: removed redundant direct CONTRACT-01 prerequisite from CORE-05; excluded `src/rough.ts` from CORE-07 so BOX-01 has one writer; made PW-03 own generator-produced package-export drift; made AGENT-03 own routing source/generated output and made final AGENT-01 wait for it; added framework lifecycle/SSR verification to BOX-02; serialized WEB-02 after WEB-01; aligned backlog dependencies. +- Generated machinery: `scripts/agent/generate.mjs` derives capability package exports from `package.json`, so PW-03 may regenerate affected artifacts but not hand-edit them. CORE-02, BOX-02, PW-03, AGENT-02/03, and final AGENT-01 remain under the delivery plan's one-writer dispatch rule. +- Release ownership: each Section 13.1 requirement maps to its named FIX, MEASURE/CORE, AD, BOX, PW/HANDOFF, SHIP, AGENT, WEB, DOCS, or RELEASE implementation/check owner. BOX-02 now explicitly owns box adapter/pattern verification rather than relying only on RELEASE-01. +- Injection dependency result: required injection runs CONTRACT-03 → PW-01 → PW-02 (plus CORE-05) → PW-03 → PW-04 → PW-05; no dependency points to FOLLOW work for optional reveals, groups, extra primitives, video, plugin, or broader examples. +- Commands and actual outcomes: repository reads/searches found all 66 backlog task packets and no extra packet; independent relative-link/path review found zero broken links or missing contract/task/evidence references. No product tests were run because this audit changes delivery documentation only. +- Explicit non-task gates: FIX-01 remains blocked pending a lead-approved website parity approach; CORE-08 requires a lead budget decision after measurement. These are recorded and may not be inferred by workers. +- Reviewer decision: ACCEPT after independent review added AGENT-03 as a prerequisite of the final AGENT-01 generator audit. diff --git a/docs/delivery/tasks/AGENT-01.md b/docs/delivery/tasks/AGENT-01.md index 86e5ee8..3b3c595 100644 --- a/docs/delivery/tasks/AGENT-01.md +++ b/docs/delivery/tasks/AGENT-01.md @@ -2,7 +2,7 @@ ## Outcome The complete first-release surface has matching installed agent facts. **Parent:** F1. **State:** BLOCKED/final audit. ## Prerequisites -AGENT-02 and PW-03 accepted. Public tasks still generate their owned artifacts +AGENT-02, PW-03 and AGENT-03 accepted. Public tasks still generate their owned artifacts when required; this task audits the integrated result after the final shared generator writer. ## Required reading diff --git a/docs/delivery/tasks/AGENT-03.md b/docs/delivery/tasks/AGENT-03.md index 2503d68..feab978 100644 --- a/docs/delivery/tasks/AGENT-03.md +++ b/docs/delivery/tasks/AGENT-03.md @@ -6,7 +6,8 @@ PW-06 and SHIP-03 accepted. ## Required reading Existing Stet skills, targeting/handoff/verification references, Playwright/shipping contracts. ## Allowed edits -Relevant skill sources and their tests. +Relevant skill sources, `agent/evals/routing.json`, generated routing evaluations +via the generator, and their tests. ## Exact change Route durable/published explanations to source and temporary artifacts to injection; surface ambiguity and limits. ## Acceptance criteria diff --git a/docs/delivery/tasks/BASE-01.md b/docs/delivery/tasks/BASE-01.md index 890b20a..522cb72 100644 --- a/docs/delivery/tasks/BASE-01.md +++ b/docs/delivery/tasks/BASE-01.md @@ -3,7 +3,7 @@ ## Outcome Reproducible revision, environment, test, browser, and size evidence before implementation. ## Parent -A1, A5, H2. **State: READY.** +A1, A5, H2. **State: ACCEPTED.** ## Prerequisites None. ## Required reading diff --git a/docs/delivery/tasks/BOX-02.md b/docs/delivery/tasks/BOX-02.md index bcaced3..29fbc1f 100644 --- a/docs/delivery/tasks/BOX-02.md +++ b/docs/delivery/tasks/BOX-02.md @@ -11,9 +11,11 @@ Primitive/index/adapters, catalog/snippet/pattern sources, tests, generated outp Add named box symbols and static/animated plan support following existing primitive patterns, and register box in the CORE-02 reveal-support mechanism. ## Acceptance criteria -Export cardinality, tree-shaking, adapter imports, schema validation and packed consumers pass. +Export cardinality, tree-shaking, adapter imports, framework lifecycle/SSR, +schema validation and packed consumers pass. ## Verification -`npm test`, `npm run check`, `npm run test:templates`, `npm run test:package`, targeted browser test. +`npm test`, `npm run check`, `npm run test:templates`, `npm run test:patterns`, +`npm run test:package`, targeted adapter/browser tests. ## Forbidden scope Universal factory, direct generated edits, other primitives, dependencies, commit/push/publish. ## Stop conditions diff --git a/docs/delivery/tasks/CONTRACT-01.md b/docs/delivery/tasks/CONTRACT-01.md index 64b8dcb..5e9f5c6 100644 --- a/docs/delivery/tasks/CONTRACT-01.md +++ b/docs/delivery/tasks/CONTRACT-01.md @@ -3,7 +3,7 @@ ## Outcome An implementation-ready state table matching `contracts/runtime.md`. ## Parent -A3. **State: BLOCKED.** +A3. **State: ACCEPTED.** ## Prerequisites Accepted BASE-01. ## Required reading diff --git a/docs/delivery/tasks/CONTRACT-02.md b/docs/delivery/tasks/CONTRACT-02.md index ff5f212..5d8c1af 100644 --- a/docs/delivery/tasks/CONTRACT-02.md +++ b/docs/delivery/tasks/CONTRACT-02.md @@ -3,7 +3,7 @@ ## Outcome Compiled, idiomatic adapter signatures and a minimal nonreplaying internal update design. ## Parent -A4 and required B6 subset. **State: BLOCKED.** +A4 and required B6 subset. **State: ACCEPTED.** ## Prerequisites Accepted CONTRACT-01. ## Required reading diff --git a/docs/delivery/tasks/CONTRACT-03.md b/docs/delivery/tasks/CONTRACT-03.md index 6eda791..f8790f6 100644 --- a/docs/delivery/tasks/CONTRACT-03.md +++ b/docs/delivery/tasks/CONTRACT-03.md @@ -3,7 +3,7 @@ ## Outcome Exact helper, remote-handle, ownership, navigation, frame, and error contracts. ## Parent -D1. **State: BLOCKED.** +D1. **State: ACCEPTED.** ## Prerequisites Accepted CONTRACT-01. ## Required reading diff --git a/docs/delivery/tasks/CONTRACT-04.md b/docs/delivery/tasks/CONTRACT-04.md index 85f5d37..8fe8105 100644 --- a/docs/delivery/tasks/CONTRACT-04.md +++ b/docs/delivery/tasks/CONTRACT-04.md @@ -3,7 +3,7 @@ ## Outcome Exact production, preview-only, local-only, disabled, mixed, and source-map output matrix. ## Parent -E1. **State: BLOCKED.** +E1. **State: ACCEPTED.** ## Prerequisites Accepted BASE-01 and CONTRACT-02. ## Required reading diff --git a/docs/delivery/tasks/CONTRACT-05.md b/docs/delivery/tasks/CONTRACT-05.md index 9d94d54..dff0456 100644 --- a/docs/delivery/tasks/CONTRACT-05.md +++ b/docs/delivery/tasks/CONTRACT-05.md @@ -3,7 +3,7 @@ ## Outcome No hidden or circular prerequisite in the first-release graph. ## Parent -Release planning. **State: BLOCKED.** +Release planning. **State: ACCEPTED.** ## Prerequisites Accepted CONTRACT-01 through CONTRACT-04. ## Required reading diff --git a/docs/delivery/tasks/CORE-01.md b/docs/delivery/tasks/CORE-01.md index 9d373b0..410a00b 100644 --- a/docs/delivery/tasks/CORE-01.md +++ b/docs/delivery/tasks/CORE-01.md @@ -3,7 +3,7 @@ ## Outcome All six current primitives implement the approved explicit visibility contract. ## Parent -B1. **State: BLOCKED.** +B1. **State: READY.** ## Prerequisites Accepted CONTRACT-01. ## Required reading diff --git a/docs/delivery/tasks/CORE-05.md b/docs/delivery/tasks/CORE-05.md index c3fc005..f46a519 100644 --- a/docs/delivery/tasks/CORE-05.md +++ b/docs/delivery/tasks/CORE-05.md @@ -6,7 +6,7 @@ reusable by later supported reveal primitives. ## Parent B4. **State: BLOCKED.** ## Prerequisites -Accepted CONTRACT-01 and CORE-04. +Accepted CORE-04 (which transitively requires CONTRACT-01). ## Required reading Runtime contract, current reveal implementation, all handle wrappers/callers. ## Allowed edits diff --git a/docs/delivery/tasks/CORE-07.md b/docs/delivery/tasks/CORE-07.md index dc27a7f..0229b2e 100644 --- a/docs/delivery/tasks/CORE-07.md +++ b/docs/delivery/tasks/CORE-07.md @@ -17,7 +17,8 @@ Same values no-op; current visibility/seed preserved; description updates safe; ## Verification Focused runtime update matrix and `npm run check`. ## Forbidden scope -Adapter edits, public update API, broad renderer refactor, commit/push/publish. +Adapter edits, `src/rough.ts`, public update API, broad renderer refactor, +commit/push/publish. ## Stop conditions Stop if a supported field cannot update without changing public behavior. ## Review evidence diff --git a/docs/delivery/tasks/MEASURE-01.md b/docs/delivery/tasks/MEASURE-01.md index 82df979..41faaa0 100644 --- a/docs/delivery/tasks/MEASURE-01.md +++ b/docs/delivery/tasks/MEASURE-01.md @@ -3,7 +3,7 @@ ## Outcome Reproducible minimal, full-core, CSS, and optional-integration measurements. ## Parent -A5. **State: BLOCKED.** +A5. **State: READY.** ## Prerequisites Accepted BASE-01. ## Required reading diff --git a/docs/delivery/tasks/PW-01.md b/docs/delivery/tasks/PW-01.md index abb6adf..37332c6 100644 --- a/docs/delivery/tasks/PW-01.md +++ b/docs/delivery/tasks/PW-01.md @@ -1,6 +1,6 @@ # PW-01 — Prove renderer and CSS loading ## Outcome -Evidence selects a loader; no production helper yet. **Parent:** D1/D2. **State:** BLOCKED. +Evidence selects a loader; no production helper yet. **Parent:** D1/D2. **State: READY.** ## Prerequisites CONTRACT-03 accepted. ## Required reading diff --git a/docs/delivery/tasks/PW-03.md b/docs/delivery/tasks/PW-03.md index b547391..69dcdee 100644 --- a/docs/delivery/tasks/PW-03.md +++ b/docs/delivery/tasks/PW-03.md @@ -4,13 +4,16 @@ ## Prerequisites PW-02 accepted. ## Required reading -Accepted PW contracts/evidence, `package.json`, TypeScript/build and package checks. +Accepted PW contracts/evidence, `package.json`, TypeScript/build and package checks, +and `scripts/agent/generate.mjs` package-export generation. ## Allowed edits -Proposed helper/bridge/build sources, package/build config, focused package tests. +Proposed helper/bridge/build sources, package/build config, focused package tests, +and generated agent artifacts changed by `npm run agent:generate` only. ## Exact change Implement only the accepted loading/package design. ## Acceptance criteria -JS/types/assets packed; caller supplies Playwright; root/core imports stay browser-only; build reproducible. +JS/types/assets packed; caller supplies Playwright; root/core imports stay browser-only; +generated capabilities list the new export with no drift; build reproducible. ## Verification `npm run check`, `npm run test:package`, fresh packed consumer, size/graph checks. ## Forbidden scope diff --git a/docs/delivery/tasks/SHIP-01.md b/docs/delivery/tasks/SHIP-01.md index 742a54d..01b5ced 100644 --- a/docs/delivery/tasks/SHIP-01.md +++ b/docs/delivery/tasks/SHIP-01.md @@ -1,6 +1,6 @@ # SHIP-01 — Build explicit exclusion fixtures ## Outcome -Production, preview, local-only, disabled, and mixed Vite modes use a review-only module boundary. **Parent:** E2. **State:** BLOCKED. +Production, preview, local-only, disabled, and mixed Vite modes use a review-only module boundary. **Parent:** E2. **State: READY.** ## Prerequisites CONTRACT-04 accepted. ## Required reading diff --git a/docs/delivery/tasks/WEB-02.md b/docs/delivery/tasks/WEB-02.md index fccf23d..148206e 100644 --- a/docs/delivery/tasks/WEB-02.md +++ b/docs/delivery/tasks/WEB-02.md @@ -2,7 +2,7 @@ ## Outcome “Write it yourself” and “Ask your agent” reach the same released result. **Parent:** G2. **State:** BLOCKED. ## Prerequisites -FIX-02, AGENT-02 and HANDOFF-01 accepted. +FIX-02, AGENT-02, HANDOFF-01 and WEB-01 accepted so the website has one writer. ## Required reading Quickstart/navigation/copy components and approved examples. ## Allowed edits From decd089d5d52c028078e7b47157eadd59f9b4af7 Mon Sep 17 00:00:00 2001 From: Sai Nimmagadda Date: Sat, 12 Sep 2026 17:31:12 -0400 Subject: [PATCH 03/11] chore: doc update --- docs/delivery/backlog.md | 14 ++++++-------- docs/delivery/tasks/FIX-01.md | 10 +++++++++- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/delivery/backlog.md b/docs/delivery/backlog.md index 5d9f447..268ea46 100644 --- a/docs/delivery/backlog.md +++ b/docs/delivery/backlog.md @@ -10,7 +10,7 @@ See [execution rules](README.md) and the approved [contracts](contracts/). | CONTRACT-03 | Required | D1 | CONTRACT-01 | ACCEPTED | | CONTRACT-04 | Required | E1 | BASE-01, CONTRACT-02 | ACCEPTED | | CONTRACT-05 | Required | Release planning | CONTRACT-01..04 | ACCEPTED | -| FIX-01 | Required | A1 | BASE-01 | BLOCKED | +| FIX-01 | Required | A1 | BASE-01 | READY | | FIX-02 | Required | A6 | FIX-01 | BLOCKED | | MEASURE-01 | Required | A5 | BASE-01 | READY | | CORE-01 | Required | B1 | CONTRACT-01 | READY | @@ -53,9 +53,8 @@ See [execution rules](README.md) and the approved [contracts](contracts/). ## Next dispatch -`MEASURE-01`, `CORE-01`, `PW-01`, and `SHIP-01` are ready and may run subject to -the shared-file writer rules. `FIX-01` remains blocked on its explicit lead-approved -parity approach. Playwright implementation still starts only after `PW-01` and +`FIX-01`, `MEASURE-01`, `CORE-01`, `PW-01`, and `SHIP-01` are ready and may run +subject to the shared-file writer rules. Playwright implementation still starts only after `PW-01` and `PW-02` produce accepted evidence. Framework writers start only after the shared runtime and adapter contract are stable. @@ -84,7 +83,6 @@ WEB-02. The release critical path is the runtime chain through CORE-07, all adapter tasks, AD-INTEGRATE, BOX-02, AGENT-02/01, plus parallel Playwright and shipping arms that -converge through AGENT-03, HANDOFF-01, WEB-02, DOCS-01, and RELEASE-01. FIX-01 has -an explicit non-task gate: its parity approach must be lead-approved. CORE-08 has -an explicit lead decision after measurement; neither approval may be inferred by -a worker. +converge through AGENT-03, HANDOFF-01, WEB-02, DOCS-01, and RELEASE-01. The FIX-01 +parity approach is approved in its task packet. CORE-08 still requires an explicit +lead decision after measurement; that approval may not be inferred by a worker. diff --git a/docs/delivery/tasks/FIX-01.md b/docs/delivery/tasks/FIX-01.md index 8514e34..3feea48 100644 --- a/docs/delivery/tasks/FIX-01.md +++ b/docs/delivery/tasks/FIX-01.md @@ -3,9 +3,17 @@ ## Outcome Stable site runtime and copied snippets match the advertised package. ## Parent -A1. **State: BLOCKED.** +A1. **State: READY.** ## Prerequisites Accepted BASE-01 and lead-approved parity approach. + +## Approved parity approach + +The stable website consumes its pinned installed `@funsaized/stet@0.1.0` runtime, +CSS, capabilities, and canonical templates. Remove checkout-source aliases and +unreleased placement controls/options from stable previews and copied snippets. +Do not add a next-version preview in this task; it is unnecessary to restore first +success and would create a second public surface to maintain. ## Required reading `website/vite.config.ts`, `website/src/constants.ts`, `website/src/components/Playground.tsx`, `website/src/showcase/canonical.ts`, website checks. ## Allowed edits From dc0d56f96cfee3a2b33388203ca0460afb1a5b2f Mon Sep 17 00:00:00 2001 From: Sai Nimmagadda Date: Sat, 12 Sep 2026 20:50:51 -0400 Subject: [PATCH 04/11] feat: phase 2 --- README.md | 13 +- docs/agent-usage.md | 13 +- docs/delivery/backlog.md | 6 +- docs/delivery/evidence/FIX-01.md | 11 ++ docs/delivery/evidence/FIX-02.md | 11 ++ docs/delivery/evidence/MEASURE-01.md | 15 ++ docs/delivery/tasks/FIX-01.md | 6 +- docs/delivery/tasks/FIX-02.md | 2 +- docs/delivery/tasks/MEASURE-01.md | 2 +- scripts-size.mjs | 75 ++++++- scripts/agent/check-package.mjs | 274 +++++++++++++++++++++----- website/README.md | 18 +- website/src/components/Playground.tsx | 44 +---- website/src/constants.ts | 2 +- website/src/pages/AgentWorkflow.tsx | 2 +- website/src/pages/Docs.tsx | 11 +- website/src/pages/FrameworkDocs.tsx | 4 +- website/src/pages/Home.tsx | 2 +- website/src/showcase/canonical.ts | 17 +- website/tests/homepage.spec.ts | 3 + website/tests/showcase.spec.ts | 17 +- website/tsconfig.json | 6 +- website/vite.config.ts | 15 -- 23 files changed, 400 insertions(+), 169 deletions(-) create mode 100644 docs/delivery/evidence/FIX-01.md create mode 100644 docs/delivery/evidence/FIX-02.md create mode 100644 docs/delivery/evidence/MEASURE-01.md diff --git a/README.md b/README.md index de21539..238d14b 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,11 @@ Use `@funsaized/stet` in installation commands and imports. npm install @funsaized/stet@0.1.0 ``` +Stet's browser API is ESM-only and uses DOM APIs, so call it from client code +after the target mounts. Import `@funsaized/stet/style.css` once in your client +entry. The browser runtime has no Node.js requirement; your build tool must support +ESM. + ## Start with an agent or the API ### With your coding agent @@ -44,11 +49,11 @@ framework/lifecycle examples. After installing `@funsaized/stet`, install project skills for your coding agent: ```sh -npx stet agent init --tool codex +./node_modules/.bin/stet agent init --tool codex # Also supports claude, cursor and opencode. ``` -For deterministic automation, use the installed binary directly: +The same installed binary provides inspection, snippets, and validation: ```sh ./node_modules/.bin/stet inspect --project . --json @@ -59,7 +64,9 @@ For deterministic automation, use the installed binary directly: The agent decides what deserves annotation, validates a source-target plan, adapts a framework-correct snippet, then checks the application. Plans are build-time tools and never replace your controls or become runtime selectors. -The CLI requires Node.js 20+; browser imports do not load agent infrastructure. +The installed CLI requires Node.js 20+; browser imports do not load agent +infrastructure. Developing this repository's website separately requires Node.js +22.12+. See the [agent guide](docs/agent-usage.md) for setup, targeting and verification, and [actual evaluation results](docs/agent-evals.md) for measured outcomes, retained failures and limitations. diff --git a/docs/agent-usage.md b/docs/agent-usage.md index 130a4c7..d0e1af0 100644 --- a/docs/agent-usage.md +++ b/docs/agent-usage.md @@ -4,14 +4,15 @@ The npm package is **@funsaized/stet**; its local CLI binary is **stet**. Instal in the application package, then install project skills for your tool: ```sh -npm install @funsaized/stet -npx stet agent init --tool codex +npm install @funsaized/stet@0.1.0 +./node_modules/.bin/stet agent init --tool codex ``` -The second command assumes the first succeeded and npm can resolve the local -binary. For automation, prefer `./node_modules/.bin/stet` (or the corresponding -hoisted installed binary); it cannot fetch a similarly named unscoped package. -The CLI needs Node.js 20 or newer. The browser runtime needs no CLI or plan. +The second command uses the binary installed by the first; it cannot fetch a +similarly named unscoped package. In a workspace, use the corresponding hoisted +installed binary. The CLI needs Node.js 20 or newer. The ESM-only browser runtime +needs no CLI, plan, or Node.js runtime; import `@funsaized/stet/style.css` in client +code and attach annotations after targets mount. Choose `claude`, `cursor`, `opencode` or `codex`. Claude uses `.claude/skills`; Cursor, OpenCode and Codex share `.agents/skills`, so installing for all three diff --git a/docs/delivery/backlog.md b/docs/delivery/backlog.md index 268ea46..fb94840 100644 --- a/docs/delivery/backlog.md +++ b/docs/delivery/backlog.md @@ -10,9 +10,9 @@ See [execution rules](README.md) and the approved [contracts](contracts/). | CONTRACT-03 | Required | D1 | CONTRACT-01 | ACCEPTED | | CONTRACT-04 | Required | E1 | BASE-01, CONTRACT-02 | ACCEPTED | | CONTRACT-05 | Required | Release planning | CONTRACT-01..04 | ACCEPTED | -| FIX-01 | Required | A1 | BASE-01 | READY | -| FIX-02 | Required | A6 | FIX-01 | BLOCKED | -| MEASURE-01 | Required | A5 | BASE-01 | READY | +| FIX-01 | Required | A1 | BASE-01 | ACCEPTED | +| FIX-02 | Required | A6 | FIX-01 | ACCEPTED | +| MEASURE-01 | Required | A5 | BASE-01 | ACCEPTED | | CORE-01 | Required | B1 | CONTRACT-01 | READY | | CORE-02 | Required | B2/F1 | CORE-01 | BLOCKED | | CORE-03 | Required | B2 | CORE-02 | BLOCKED | diff --git a/docs/delivery/evidence/FIX-01.md b/docs/delivery/evidence/FIX-01.md new file mode 100644 index 0000000..792f54a --- /dev/null +++ b/docs/delivery/evidence/FIX-01.md @@ -0,0 +1,11 @@ +# FIX-01 evidence + +- Revision/base: `decd089` +- Environment: Linux 7.2.3 x86_64; Node v26.7.0; npm 11.19.0 +- Files changed: `website/vite.config.ts`, `website/tsconfig.json`, `website/src/constants.ts`, `website/src/components/Playground.tsx`, `website/src/pages/FrameworkDocs.tsx`, `website/src/showcase/canonical.ts`, and `website/tests/showcase.spec.ts`. +- Acceptance criteria checked: the stable runtime, CSS, capabilities, and canonical templates resolve from installed `@funsaized/stet@0.1.0`; copied arrow and sticky snippets omit unreleased placement options, including zero values; displayed release links and install copy remain 0.1.0. +- Commands and exit status: `npm --prefix website run check` — exit 0 on both final worker and orchestrator runs; `npm --prefix website test` — the initial concurrent orchestrator run had preview-server interference, an isolated run with temporary worker snapshots passed 88 tests, and the final run after reverting those snapshots returned the exact accepted BASE-01 local-renderer result: 84 passed and four screenshot comparisons failed (desktop workspace 1,053 pixels, security handoff 1,045, form review 1,064, and mobile workspace 2); fresh `/tmp/stet-snippet-consumer-fix01` installed npm release 0.1.0 and `npm exec tsc -- --strict --noEmit --module ESNext --moduleResolution Bundler --target ES2022 --lib ES2022,DOM src/*.ts` — exit 0. +- Browser/manual artifacts: before, copied arrow code contained `labelOffsetX: 0` and `labelOffsetY: 0`; after, it contains only released options. Sticky code likewise omits `offsetX` and `offsetY`. Four worker-updated snapshots were reverted after review found host-rendering noise rather than justified product changes. +- Pre-existing failures: BASE-01 records the same four local Chromium screenshot mismatches on this Arch host; all functional website tests passed. The initial connection-refused run was orchestration interference. +- Unverified items and reason: the four committed Ubuntu/CI snapshots were not reproduced on an Ubuntu 24.04 runner; local Docker access is unavailable. No snapshot was changed. +- Reviewer decision: APPROVE. The reviewer confirmed the aliases and copy were corrected, all PNG changes were reverted, the fresh 0.1.0 consumer compiles, and the four local visual mismatches exactly match accepted BASE-01 evidence rather than a FIX-01 regression. diff --git a/docs/delivery/evidence/FIX-02.md b/docs/delivery/evidence/FIX-02.md new file mode 100644 index 0000000..4ff9ee2 --- /dev/null +++ b/docs/delivery/evidence/FIX-02.md @@ -0,0 +1,11 @@ +# FIX-02 evidence + +- Revision/base: `decd089` +- Environment: Linux 7.2.3 x86_64; Node v26.7.0; npm 11.19.0 +- Files changed: `README.md`, `docs/agent-usage.md`, `scripts/agent/check-package.mjs`, `website/README.md`, `website/src/pages/Docs.tsx`, `website/src/pages/AgentWorkflow.tsx`, `website/src/pages/Home.tsx`, `website/tests/homepage.spec.ts`, and `website/tests/showcase.spec.ts`. +- Acceptance criteria checked: canonical copy installs scoped `@funsaized/stet@0.1.0`, imports its ESM API and CSS in client code, and invokes the installed `./node_modules/.bin/stet` binary; CLI Node 20+, browser runtime, and website-development Node 22.12+ requirements are distinct. The packed fresh-consumer check exercises ESM imports, CSS emission, and the local binary without runtime dependencies. +- Commands and exit status: final `npm run test:package` — exit 0 (`702,428` archive bytes, `1,321,143` unpacked bytes, 260 files, zero consumer runtime dependencies, 3,387-byte circle gzip, 4,986-byte full gzip); `npm pack --ignore-scripts --json --pack-destination /tmp/opencode/fix02` — produced `/tmp/opencode/fix02/funsaized-stet-0.1.0.tgz`; final `npm run test:cli-consumer -- /tmp/opencode/fix02/funsaized-stet-0.1.0.tgz` — exit 0 (`ok: true`, local binary present, zero required dependencies); final `npm --prefix website run check` — exit 0; `npm --prefix website test` — 84 passed and the same four local visual comparisons failed with the exact BASE-01 pixel counts. +- Browser/manual artifacts: homepage visible and copied install commands are both pinned; website workflow and docs display the explicit installed binary path. No snapshots changed. +- Pre-existing failures: BASE-01 records the same Arch Chromium mismatches: desktop workspace 1,053 pixels, security handoff 1,045, form review 1,064, and mobile workspace 2. All functional website tests passed. +- Unverified items and reason: Node 20.0.0 and 24 CI jobs were not reproduced on this Node 26 host; their accepted repository CI evidence establishes the documented CLI floor. Ubuntu website snapshots were not reproduced locally. +- Reviewer decision: APPROVE. The reviewer verified the packed artifact, CSS output, browser bundle sizes, local CLI consumer, unchanged package/bin/Node floors, and absence of PNG changes. Three nonblocking stale-copy findings were corrected within scope and the affected checks reran successfully. diff --git a/docs/delivery/evidence/MEASURE-01.md b/docs/delivery/evidence/MEASURE-01.md new file mode 100644 index 0000000..795897d --- /dev/null +++ b/docs/delivery/evidence/MEASURE-01.md @@ -0,0 +1,15 @@ +# MEASURE-01 evidence + +- Revision/base: `decd089` +- Environment: Linux 7.2.3 x86_64; Node v26.7.0; npm 11.19.0; esbuild 0.28.2 from existing dev dependencies. +- Files changed: `scripts-size.mjs` and `scripts/agent/check-package.mjs`. +- Acceptance criteria checked: the unchanged legacy core and CSS calculations preserve comparable limits; esbuild creates minified browser ESM bundles for a single `circle`, the full core namespace, and each current framework adapter; every graph allowlists only expected browser `dist` modules and verifies its exact external host-framework imports; the minimal bundle's byte delta from full core measures in-module tree-shaking. +- Commands and exit status: worker `npm run size` and `npm run test:package` — exit 0; first post-review `npm run size` — exit 1 because Angular emitted the same allowed `@angular/core` external twice; the check was corrected to compare unique external specifiers; final formatted `npm run size` — exit 0; final `npm run test:package` — exit 0 (`ok: true`, 703,112 archive bytes, 1,323,304 unpacked bytes, 261 files, zero consumer runtime dependencies, 3,387-byte circle and 4,986-byte full bundles); `oxfmt --check` initially reported both modified scripts, then `oxfmt` and `oxlint --deny-warnings` completed successfully. +- Browser/manual artifacts: legacy core 6,989 B gzip; CSS 1,138 B gzip; minimal circle 3,387 B; full core 4,986 B; React 5,435 B; Vue 5,256 B; Svelte 5,208 B; Angular 5,948 B. Adapter figures are total Stet runtime plus adapter glue with host peers externalized. +- Pre-existing failures: none. +- Unverified items and reason: no Playwright or build-tool package entry exists yet, so no such optional integration was measured or implemented. Future optional entries must add their own cases. +- Reviewer decision: APPROVE. The reviewer independently reproduced both required commands and verified that the allowlists reject agent, Node, Playwright, host-framework, and unexpected dist inputs while permitting only each declared external. + +Current enforced limits remain exactly 7 KiB for the legacy core calculation and +3 KiB for raw CSS. The new consumer and adapter measurements are reports, not new +or raised budgets. diff --git a/docs/delivery/tasks/FIX-01.md b/docs/delivery/tasks/FIX-01.md index 3feea48..f76adb8 100644 --- a/docs/delivery/tasks/FIX-01.md +++ b/docs/delivery/tasks/FIX-01.md @@ -3,7 +3,7 @@ ## Outcome Stable site runtime and copied snippets match the advertised package. ## Parent -A1. **State: READY.** +A1. **State: ACCEPTED.** ## Prerequisites Accepted BASE-01 and lead-approved parity approach. @@ -17,7 +17,9 @@ success and would create a second public surface to maintain. ## Required reading `website/vite.config.ts`, `website/src/constants.ts`, `website/src/components/Playground.tsx`, `website/src/showcase/canonical.ts`, website checks. ## Allowed edits -Listed website files and directly relevant website tests. +Listed website files, `website/tsconfig.json`, +`website/src/pages/FrameworkDocs.tsx`, and directly relevant website tests. The +two additional files were authorized by the lead after independent review. ## Exact change Remove stable dependence on checkout-only capabilities or isolate a visibly separate preview. ## Acceptance criteria diff --git a/docs/delivery/tasks/FIX-02.md b/docs/delivery/tasks/FIX-02.md index f6b01fd..fda1b54 100644 --- a/docs/delivery/tasks/FIX-02.md +++ b/docs/delivery/tasks/FIX-02.md @@ -3,7 +3,7 @@ ## Outcome One tested scoped-package, CSS, ESM, CLI, and Node installation path. ## Parent -A6. **State: BLOCKED.** +A6. **State: ACCEPTED.** ## Prerequisites Accepted FIX-01. ## Required reading diff --git a/docs/delivery/tasks/MEASURE-01.md b/docs/delivery/tasks/MEASURE-01.md index 41faaa0..8aeb488 100644 --- a/docs/delivery/tasks/MEASURE-01.md +++ b/docs/delivery/tasks/MEASURE-01.md @@ -3,7 +3,7 @@ ## Outcome Reproducible minimal, full-core, CSS, and optional-integration measurements. ## Parent -A5. **State: READY.** +A5. **State: ACCEPTED.** ## Prerequisites Accepted BASE-01. ## Required reading diff --git a/scripts-size.mjs b/scripts-size.mjs index 31a61f1..b19ed19 100644 --- a/scripts-size.mjs +++ b/scripts-size.mjs @@ -1,12 +1,81 @@ import { readFileSync } from "node:fs"; import { gzipSync } from "node:zlib"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +// Legacy raw-concatenation baselines. Kept byte-for-byte comparable with the +// 7 KiB core / 3 KiB CSS guards from 0.0.1; do not fold bundle output in here. const core = ["prng", "rough", "mount", "primitives", "index"] .map((name) => readFileSync(new URL(`dist/${name}.js`, import.meta.url))) .join("\n"); const css = readFileSync(new URL("style.css", import.meta.url)); const sizes = { core: gzipSync(core).length, css: gzipSync(css).length }; -console.log(`core: ${(sizes.core / 1024).toFixed(2)} KB gzip`); -console.log(`css: ${(sizes.css / 1024).toFixed(2)} KB gzip`); -// Scroll-context detection and the shared scheduler add ~0.8 KB over 0.0.1. +const kb = (bytes) => (bytes / 1024).toFixed(2); +console.log(`core: ${kb(sizes.core)} KB gzip (${sizes.core} B)`); +console.log(`css: ${kb(sizes.css)} KB gzip (${sizes.css} B)`); + +// Browser ESM consumer bundles built with the installed esbuild from the fresh +// dist output. Host framework peers are external so their bytes are not +// attributed to Stet. Each adapter figure is the total adapter-consumer bundle +// (Stet runtime + that adapter's glue), not adapter-only bytes. +const root = fileURLToPath(new URL(".", import.meta.url)); +const consumers = { + "minimal circle": { + source: 'import { circle } from "./dist/index.js"; console.log(circle);', + external: [], + }, + "full core": { + source: 'import * as stet from "./dist/index.js"; console.log(stet);', + external: [], + }, + react: { + source: 'import * as stet from "./dist/react.js"; console.log(stet);', + external: ["react"], + }, + vue: { source: 'import * as stet from "./dist/vue.js"; console.log(stet);', external: [] }, + svelte: { source: 'import * as stet from "./dist/svelte.js"; console.log(stet);', external: [] }, + angular: { + source: 'import * as stet from "./dist/angular.js"; console.log(stet);', + external: ["@angular/core"], + }, +}; +const browserModules = new Set( + ["prng", "rough", "mount", "primitives", "index", "react", "vue", "svelte", "angular"].map( + (name) => `dist/${name}.js`, + ), +); +const bundles = {}; +for (const [label, { source, external }] of Object.entries(consumers)) { + const result = await build({ + stdin: { contents: source, resolveDir: root, sourcefile: `${label}.js` }, + bundle: true, + write: false, + minify: true, + platform: "browser", + format: "esm", + external, + metafile: true, + }); + const unexpectedInputs = Object.keys(result.metafile.inputs) + .map((path) => path.replaceAll("\\", "/")) + .filter((path) => path !== `${label}.js` && !browserModules.has(path)); + if (unexpectedInputs.length) + throw new Error(`${label} bundle pulled in unexpected code: ${unexpectedInputs.join(", ")}`); + const outputImports = [ + ...new Set( + Object.values(result.metafile.outputs).flatMap((output) => + output.imports.map(({ path }) => path), + ), + ), + ].sort(); + if (JSON.stringify(outputImports) !== JSON.stringify([...external].sort())) + throw new Error(`${label} bundle externals changed: ${outputImports.join(", ")}`); + bundles[label] = gzipSync(result.outputFiles[0].contents).length; +} +if (!(bundles["minimal circle"] < bundles["full core"])) + throw new Error("tree-shaking regressed: minimal circle is not smaller than full core"); +console.log("browser bundles (esbuild ESM, minified gzip):"); +for (const [label, bytes] of Object.entries(bundles)) + console.log(`${label}: ${kb(bytes)} KB (${bytes} B)`); + if (sizes.core > 7 * 1024 || sizes.css > 3 * 1024) process.exitCode = 1; diff --git a/scripts/agent/check-package.mjs b/scripts/agent/check-package.mjs index bbb1e8c..aaf4cbf 100644 --- a/scripts/agent/check-package.mjs +++ b/scripts/agent/check-package.mjs @@ -1,43 +1,144 @@ -import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; -import { resolve, join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { execFileSync } from 'node:child_process'; -import assert from 'node:assert/strict'; -import { build } from 'esbuild'; -import { gzipSync } from 'node:zlib'; -const cwd = mkdtempSync(join(tmpdir(), 'stet-package-')); -const run = (cmd, args, options = {}) => execFileSync(cmd, args, { encoding: 'utf8', ...options }); +import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { tmpdir } from "node:os"; +import { execFileSync } from "node:child_process"; +import assert from "node:assert/strict"; +import { build } from "esbuild"; +import { gzipSync } from "node:zlib"; +const cwd = mkdtempSync(join(tmpdir(), "stet-package-")); +const run = (cmd, args, options = {}) => execFileSync(cmd, args, { encoding: "utf8", ...options }); try { - run('npm', ['run', 'build'], { stdio: 'inherit' }); - const [pack] = JSON.parse(run('npm', ['pack', '--ignore-scripts', '--json', '--cache', join(cwd, 'cache'), '--pack-destination', cwd])); - const paths = new Set(pack.files.map(f => f.path)); - const pkg = JSON.parse(readFileSync('package.json', 'utf8')); - assert.deepEqual(pkg.peerDependencies, { '@angular/core': '>=20 <22', react: '>=18 <20', vue: '>=3 <4' }); - assert(Object.values(pkg.peerDependenciesMeta).every(meta => meta.optional === true)); + run("npm", ["run", "build"], { stdio: "inherit" }); + const [pack] = JSON.parse( + run("npm", [ + "pack", + "--ignore-scripts", + "--json", + "--cache", + join(cwd, "cache"), + "--pack-destination", + cwd, + ]), + ); + const paths = new Set(pack.files.map((f) => f.path)); + const pkg = JSON.parse(readFileSync("package.json", "utf8")); + assert.deepEqual(pkg.peerDependencies, { + "@angular/core": ">=20 <22", + react: ">=18 <20", + vue: ">=3 <4", + }); + assert(Object.values(pkg.peerDependenciesMeta).every((meta) => meta.optional === true)); assert.equal(pkg.dependencies, undefined); - assert.deepEqual(pkg.sideEffects, ['./style.css']); - for (const path of ['agent/cli.mjs', 'agent/project.mjs', 'agent/examples/settings.plan.json', 'agent/LICENSE-ajv.txt', 'agent/index.d.ts', 'agent/annotation-plan.d.ts', 'agent/validate.generated.mjs', 'agent/capabilities.json', 'agent/schemas/annotation-plan.schema.json', 'agent/schemas/capabilities.schema.json', 'style.css', 'style.css.d.ts']) assert(paths.has(path), `Missing packed ${path}`); - for (const name of ['index', 'mount', 'primitives', 'rough', 'prng', 'react', 'vue', 'svelte', 'angular']) for (const ext of ['js','d.ts']) assert(paths.has(`dist/${name}.${ext}`)); - function walk(dir) { return readdirSync(dir, { withFileTypes: true }).flatMap(e => e.isDirectory() ? walk(`${dir}/${e.name}`) : [`${dir}/${e.name}`]); } - for (const path of [...walk('agent/skills'), ...walk('agent/templates')]) assert(paths.has(path), `Missing packed ${path}`); - const app = join(cwd, 'consumer'); mkdirSync(app); - writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'stet-package-consumer', private: true, type: 'module' })); - run('npm', ['install', join(cwd, pack.filename), '--ignore-scripts', '--offline', '--cache', join(cwd, 'cache')], { cwd: app }); - const cli = join(app, 'node_modules/.bin/stet'); - const inspected = JSON.parse(run(cli, ['inspect', '--json'], { cwd: app })); + assert.deepEqual(pkg.sideEffects, ["./style.css"]); + for (const path of [ + "agent/cli.mjs", + "agent/project.mjs", + "agent/examples/settings.plan.json", + "agent/LICENSE-ajv.txt", + "agent/index.d.ts", + "agent/annotation-plan.d.ts", + "agent/validate.generated.mjs", + "agent/capabilities.json", + "agent/schemas/annotation-plan.schema.json", + "agent/schemas/capabilities.schema.json", + "style.css", + "style.css.d.ts", + ]) + assert(paths.has(path), `Missing packed ${path}`); + for (const name of [ + "index", + "mount", + "primitives", + "rough", + "prng", + "react", + "vue", + "svelte", + "angular", + ]) + for (const ext of ["js", "d.ts"]) assert(paths.has(`dist/${name}.${ext}`)); + function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((e) => + e.isDirectory() ? walk(`${dir}/${e.name}`) : [`${dir}/${e.name}`], + ); + } + for (const path of [...walk("agent/skills"), ...walk("agent/templates")]) + assert(paths.has(path), `Missing packed ${path}`); + const app = join(cwd, "consumer"); + mkdirSync(app); + writeFileSync( + join(app, "package.json"), + JSON.stringify({ name: "stet-package-consumer", private: true, type: "module" }), + ); + run( + "npm", + [ + "install", + join(cwd, pack.filename), + "--ignore-scripts", + "--offline", + "--cache", + join(cwd, "cache"), + ], + { cwd: app }, + ); + const cli = join(app, "node_modules/.bin/stet"); + const inspected = JSON.parse(run(cli, ["inspect", "--json"], { cwd: app })); assert.equal(inspected.capabilities.package.version, pkg.version); assert.equal(inspected.capabilities.package.name, pkg.name); - assert.equal(JSON.parse(run(cli, ['schema', 'annotation-plan'], { cwd: app })).properties.version.const, 1); - for (const framework of Object.keys(inspected.capabilities.frameworks)) for (const primitive of Object.keys(inspected.capabilities.primitives)) assert(JSON.parse(run(cli, ['snippet', primitive, '--framework', framework, '--json'], { cwd: app })).code.includes('@funsaized/stet')); - for (const framework of Object.keys(inspected.capabilities.frameworks)) assert(JSON.parse(run(cli, ['snippet', '--pattern', 'lifecycle', '--framework', framework, '--json'], { cwd: app })).code.includes('attachMarks')); - assert.equal(JSON.parse(run(cli, ['inspect', '--project', app, '--json'], { cwd: app })).project.installed.version, pkg.version); - assert.equal(JSON.parse(run(cli, ['validate', join(app, 'node_modules/@funsaized/stet/agent/examples/settings.plan.json'), '--json'], { cwd: app })).ok, true); - const fixture = JSON.parse(readFileSync('agent/evals/plans.json', 'utf8'))[0].plan; - writeFileSync(join(app, 'plan.json'), JSON.stringify(fixture)); - assert.equal(JSON.parse(run(cli, ['validate', 'plan.json', '--json'], { cwd: app })).ok, true); - assert.equal(JSON.parse(run(cli, ['agent', 'init', '--tool', 'codex', '--json'], { cwd: app })).skills.length, 4); - assert.deepEqual(JSON.parse(run(cli, ['agent', 'update', '--tool', 'codex', '--json'], { cwd: app })).changed, []); - writeFileSync(join(app, 'smoke.mjs'), `import assert from 'node:assert/strict'; + assert.equal( + JSON.parse(run(cli, ["schema", "annotation-plan"], { cwd: app })).properties.version.const, + 1, + ); + for (const framework of Object.keys(inspected.capabilities.frameworks)) + for (const primitive of Object.keys(inspected.capabilities.primitives)) + assert( + JSON.parse( + run(cli, ["snippet", primitive, "--framework", framework, "--json"], { cwd: app }), + ).code.includes("@funsaized/stet"), + ); + for (const framework of Object.keys(inspected.capabilities.frameworks)) + assert( + JSON.parse( + run(cli, ["snippet", "--pattern", "lifecycle", "--framework", framework, "--json"], { + cwd: app, + }), + ).code.includes("attachMarks"), + ); + assert.equal( + JSON.parse(run(cli, ["inspect", "--project", app, "--json"], { cwd: app })).project.installed + .version, + pkg.version, + ); + assert.equal( + JSON.parse( + run( + cli, + [ + "validate", + join(app, "node_modules/@funsaized/stet/agent/examples/settings.plan.json"), + "--json", + ], + { cwd: app }, + ), + ).ok, + true, + ); + const fixture = JSON.parse(readFileSync("agent/evals/plans.json", "utf8"))[0].plan; + writeFileSync(join(app, "plan.json"), JSON.stringify(fixture)); + assert.equal(JSON.parse(run(cli, ["validate", "plan.json", "--json"], { cwd: app })).ok, true); + assert.equal( + JSON.parse(run(cli, ["agent", "init", "--tool", "codex", "--json"], { cwd: app })).skills + .length, + 4, + ); + assert.deepEqual( + JSON.parse(run(cli, ["agent", "update", "--tool", "codex", "--json"], { cwd: app })).changed, + [], + ); + writeFileSync( + join(app, "smoke.mjs"), + `import assert from 'node:assert/strict'; import * as core from '@funsaized/stet'; import * as actions from '@funsaized/stet/svelte'; import * as directives from '@funsaized/stet/vue'; @@ -45,9 +146,12 @@ import { validatePlan } from '@funsaized/stet/agent'; assert.equal(typeof core.circle, 'function'); assert.equal(typeof actions.arrow, 'function'); assert(directives.vStetCircle); assert.equal(validatePlan({}).ok, false); for (const subpath of ['', '/react', '/vue', '/svelte', '/angular', '/style.css', '/agent', '/agent/capabilities.json', '/agent/schemas/annotation-plan.schema.json']) assert(import.meta.resolve('@funsaized/stet' + subpath)); -`); - run(process.execPath, ['smoke.mjs'], { cwd: app }); - writeFileSync(join(app, 'types.ts'), `import { validatePlan, type AnnotationPlan } from '@funsaized/stet/agent'; +`, + ); + run(process.execPath, ["smoke.mjs"], { cwd: app }); + writeFileSync( + join(app, "types.ts"), + `import { validatePlan, type AnnotationPlan } from '@funsaized/stet/agent'; const plan = ${JSON.stringify(fixture)} satisfies AnnotationPlan; const ok: boolean = validatePlan(plan).ok; // @ts-expect-error Unsupported framework must be rejected by packed declarations. @@ -56,17 +160,89 @@ const wrongFramework: AnnotationPlan['framework'] = 'solid'; const missingText: AnnotationPlan['annotations'][number] = { id: 'note', primitive: 'sticky', targets: ${JSON.stringify(fixture.annotations[0].targets)} }; // @ts-expect-error Arrow requires exactly two targets. const missingDestination: AnnotationPlan['annotations'][number] = { id: 'link', primitive: 'arrow', targets: ${JSON.stringify(fixture.annotations[0].targets)} }; -`); - run(process.execPath, [resolve('node_modules/typescript/bin/tsc'), '--strict', '--noEmit', '--skipLibCheck', '--target', 'ES2022', '--module', 'NodeNext', '--moduleResolution', 'NodeNext', 'types.ts'], { cwd: app }); +`, + ); + run( + process.execPath, + [ + resolve("node_modules/typescript/bin/tsc"), + "--strict", + "--noEmit", + "--skipLibCheck", + "--target", + "ES2022", + "--module", + "NodeNext", + "--moduleResolution", + "NodeNext", + "types.ts", + ], + { cwd: app }, + ); const bundles = {}; - for (const [label, source] of Object.entries({ circle: 'import { circle } from "@funsaized/stet"; console.log(circle);', full: 'import * as stet from "@funsaized/stet"; console.log(stet);' })) { - const bundle = await build({ stdin: { contents: source, resolveDir: app }, bundle: true, write: false, minify: true, platform: 'browser', format: 'esm', metafile: true }); - assert(Object.keys(bundle.metafile.inputs).every(p => !p.includes('/agent/') && !p.includes('node:')), 'Agent infrastructure leaked into browser bundle'); + const browserModules = new Set(["prng.js", "rough.js", "mount.js", "primitives.js", "index.js"]); + for (const [label, source] of Object.entries({ + circle: 'import { circle } from "@funsaized/stet"; console.log(circle);', + full: 'import * as stet from "@funsaized/stet"; console.log(stet);', + })) { + const bundle = await build({ + stdin: { contents: source, resolveDir: app }, + bundle: true, + write: false, + minify: true, + platform: "browser", + format: "esm", + metafile: true, + }); + assert( + Object.keys(bundle.metafile.inputs).every((path) => { + const normalized = path.replaceAll("\\", "/"); + return ( + normalized === "" || + (normalized.includes("node_modules/@funsaized/stet/dist/") && + browserModules.has(normalized.split("/").at(-1))) + ); + }), + "Unexpected package input leaked into browser bundle", + ); + assert( + Object.values(bundle.metafile.outputs).every((output) => output.imports.length === 0), + "Unexpected external import leaked into browser bundle", + ); bundles[label] = gzipSync(bundle.outputFiles[0].contents).length; } - assert(bundles.circle < bundles.full, 'Unused primitives were not tree-shaken'); + const styled = await build({ + stdin: { contents: 'import "@funsaized/stet/style.css";', resolveDir: app }, + bundle: true, + write: false, + outdir: "out", + platform: "browser", + format: "esm", + }); + assert( + styled.outputFiles.some( + (file) => file.path.endsWith(".css") && file.text.includes(".stet-overlay"), + ), + "Packed CSS import did not emit Stet styles", + ); + assert(bundles.circle < bundles.full, "Unused primitives were not tree-shaken"); // The only required installed package should be Stet; peers remain optional. - const lock = JSON.parse(readFileSync(join(app, 'package-lock.json'), 'utf8')); - assert.deepEqual(Object.keys(lock.packages).sort(), ['', 'node_modules/@funsaized/stet']); - console.log(JSON.stringify({ ok: true, archiveBytes: pack.size, unpackedBytes: pack.unpackedSize, files: pack.files.length, consumerRuntimeDependencies: 0, browserGzipBytes: bundles }, null, 2)); -} finally { rmSync(cwd, { recursive: true, force: true }); } + const lock = JSON.parse(readFileSync(join(app, "package-lock.json"), "utf8")); + assert.deepEqual(Object.keys(lock.packages).sort(), ["", "node_modules/@funsaized/stet"]); + console.log( + JSON.stringify( + { + ok: true, + archiveBytes: pack.size, + unpackedBytes: pack.unpackedSize, + files: pack.files.length, + consumerRuntimeDependencies: 0, + browserGzipBytes: bundles, + }, + null, + 2, + ), + ); +} finally { + rmSync(cwd, { recursive: true, force: true }); +} diff --git a/website/README.md b/website/README.md index a845927..cb97761 100644 --- a/website/README.md +++ b/website/README.md @@ -2,17 +2,15 @@ The Stet product website, built with Vite, React, TypeScript, and TanStack Router. Public routes are rendered to HTML at build time and hydrated in the browser. -The demonstrations use **this checkout's actual runtime** through Vite aliases and -TypeScript paths, with React deduplication. Canonical capability JSON and framework -templates come directly from `../agent/`; the browser does not import CLI or -validation code. This app now requires the repository checkout, rather than an -isolated copy of `website/`. Root and website dependencies must both be installed. -The published dependency remains pinned to 0.1.0 for release identity; aliases -intentionally exercise upcoming runtime changes before they ship. +The demonstrations use the website's pinned `@funsaized/stet@0.1.0` dependency, +including its runtime, CSS, capability JSON, and canonical framework templates. +The browser does not import CLI or validation code. ## Development -Use Node.js 22.12+ and npm. +Use Node.js 22.12+ and npm to develop or build this website. This is not the Stet +browser runtime's Node requirement; the separately installed Stet CLI supports +Node.js 20+. ```sh npm ci @@ -29,7 +27,7 @@ Start at `/use-cases`. Direct links are: - `/use-cases/guided-tutorial`: a dedicated deployment form with branch and environment choices. - `/use-cases/live-documentation`: a working activity inbox with filters, expandable details and read state. - `/agent-workflow`: deterministic walkthrough of the shipped authoring pipeline. -- `/playground`: actual primitive options, nudges, seed, reset and copy. +- `/playground`: published primitive options, seed, reset and copy. - `/docs`: integration instructions and canonical framework examples. The homepage is `/`. Fonts are bundled with the app; there are no external font @@ -81,7 +79,7 @@ Import `funsaized/stet` into Vercel and configure: Enable **Include source files outside of the Root Directory in the Build Step** in Vercel's project settings, as described in the [Vercel monorepo documentation](https://vercel.com/docs/monorepos/monorepo-faq). -This is needed for the checkout runtime and canonical agent assets. +This is needed because the website build runs from the repository checkout. `website/vercel.json` uses clean URLs and no trailing slash. Each public route has its own HTML file; there is no SPA catch-all rewrite. Missing paths receive the diff --git a/website/src/components/Playground.tsx b/website/src/components/Playground.tsx index 151ca70..db88ccc 100644 --- a/website/src/components/Playground.tsx +++ b/website/src/components/Playground.tsx @@ -18,8 +18,6 @@ export function Playground() { [curvature, setCurvature] = useState(0.16), [side, setSide] = useState<'auto' | 'top' | 'right' | 'bottom' | 'left'>('bottom'), [label, setLabel] = useState('this way'), - [offsetX, setOffsetX] = useState(0), - [offsetY, setOffsetY] = useState(0), [hover, setHover] = useState(false); function reset() { setKind('circle'); @@ -32,8 +30,6 @@ export function Playground() { setCurvature(0.16); setSide('bottom'); setLabel('this way'); - setOffsetX(0); - setOffsetY(0); setHover(false); } const target = useRef(null), @@ -65,11 +61,9 @@ export function Playground() { fill: paperColor, text: 'A little note, just for you.', side, - offsetX, - offsetY, } : {}), - ...(kind === 'arrow' ? { curvature, label, labelOffsetX: offsetX, labelOffsetY: offsetY } : {}), + ...(kind === 'arrow' ? { curvature, label } : {}), ...(kind === 'mark' ? { description: 'Looking good' } : {}), }; const options = { target, ...shared }; @@ -164,8 +158,6 @@ export function Playground() { label={label} width={width} curvature={curvature} - labelOffsetX={offsetX} - labelOffsetY={offsetY} resketchOnHover={hover} /> )} @@ -176,8 +168,6 @@ export function Playground() { fill={paperColor} text="A little note, just for you." side={side} - offsetX={offsetX} - offsetY={offsetY} /> )} {kind === 'mark' && } @@ -244,10 +234,6 @@ export function Playground() {
Placement & reproducibility -

- Pixel nudges preview the next release in this checkout. Inspect installed - capabilities before using them with published 0.1.0. -

{kind !== 'sticky' && kind !== 'highlight' && ( )} - {(kind === 'sticky' || kind === 'arrow') && ( - <> - - -

- Applied after automatic placement, then clamped to the viewport. Inspect for - overlaps. -

- - )}
diff --git a/website/src/constants.ts b/website/src/constants.ts index d487f3d..daa32b9 100644 --- a/website/src/constants.ts +++ b/website/src/constants.ts @@ -1,4 +1,4 @@ -import capabilities from '../../agent/capabilities.json'; +import capabilities from '@funsaized/stet/agent/capabilities.json'; export const REPO = 'https://github.com/funsaized/stet'; export const INSTALL = 'npm install @funsaized/stet@0.1.0'; export type Kind = keyof typeof capabilities.primitives; diff --git a/website/src/pages/AgentWorkflow.tsx b/website/src/pages/AgentWorkflow.tsx index 7efaebc..d7b298a 100644 --- a/website/src/pages/AgentWorkflow.tsx +++ b/website/src/pages/AgentWorkflow.tsx @@ -21,7 +21,7 @@ const stages = [ 'I’ll use Stet to explain the risky action and keep the existing warning and confirmation controls in place.', title: 'Choose the authoring workflow.', body: 'stet-explain-ui guides emphasis and preservation of native warnings. Review and showcase have different goals; all use the base implementation workflow.', - code: 'npm install @funsaized/stet@0.1.0\nnpx stet agent init --tool codex\n# Also supports claude, cursor and opencode.', + code: 'npm install @funsaized/stet@0.1.0\n./node_modules/.bin/stet agent init --tool codex\n# Also supports claude, cursor and opencode.', }, { label: 'Inspect', diff --git a/website/src/pages/Docs.tsx b/website/src/pages/Docs.tsx index 3a65d39..46d6df3 100644 --- a/website/src/pages/Docs.tsx +++ b/website/src/pages/Docs.tsx @@ -48,10 +48,15 @@ export function Docs() {

With your coding agent

Install Stet’s project skills from your app directory. The CLI requires Node.js 20 or - newer. + newer. The stet binary comes from the installed{' '} + @funsaized/stet package; use its local path below rather than fetching an + unscoped package.

- - npx stet agent init --tool codex + + ./node_modules/.bin/stet agent init --tool codex

Choose codex, claude, cursor, or{' '} diff --git a/website/src/pages/FrameworkDocs.tsx b/website/src/pages/FrameworkDocs.tsx index e597715..f58d670 100644 --- a/website/src/pages/FrameworkDocs.tsx +++ b/website/src/pages/FrameworkDocs.tsx @@ -64,9 +64,7 @@ export function FrameworkDocs() {

Keep marks in step with the interface

- These lifecycle templates follow the repository checkout. Placement nudges such as{' '} - labelOffsetY are unreleased; omit them with published 0.1.0 and check your - installed capabilities before adapting the example. + These lifecycle templates are copied from installed @funsaized/stet@0.1.0.

{framework.lifecycle}

diff --git a/website/src/pages/Home.tsx b/website/src/pages/Home.tsx index a7857e6..bcd2095 100644 --- a/website/src/pages/Home.tsx +++ b/website/src/pages/Home.tsx @@ -44,7 +44,7 @@ export function Home() {
$ - npm install @funsaized/stet + {INSTALL}
Open source. Zero runtime dependencies. Yours to scribble with. diff --git a/website/src/showcase/canonical.ts b/website/src/showcase/canonical.ts index a7525c4..dcde2b0 100644 --- a/website/src/showcase/canonical.ts +++ b/website/src/showcase/canonical.ts @@ -1,13 +1,16 @@ -import capabilities from '../../../agent/capabilities.json'; -const templates = import.meta.glob('../../../agent/templates/**/*', { - query: '?raw', - import: 'default', - eager: true, -}); +import capabilities from '@funsaized/stet/agent/capabilities.json'; +const templates = import.meta.glob( + '../../node_modules/@funsaized/stet/agent/templates/**/*', + { + query: '?raw', + import: 'default', + eager: true, + }, +); export { capabilities }; export type Framework = keyof typeof capabilities.frameworks; export function canonicalCode(framework: Framework, primitive = 'circle') { return templates[ - `../../../agent/templates/${framework}/${primitive}.${capabilities.frameworks[framework].extension}` + `../../node_modules/@funsaized/stet/agent/templates/${framework}/${primitive}.${capabilities.frameworks[framework].extension}` ]; } diff --git a/website/tests/homepage.spec.ts b/website/tests/homepage.spec.ts index b55493c..dabc523 100644 --- a/website/tests/homepage.spec.ts +++ b/website/tests/homepage.spec.ts @@ -143,6 +143,9 @@ test('copy controls copy the pinned install command and current example', async test.skip(browserName !== 'chromium', 'Clipboard permissions are Chromium-specific.'); await context.grantPermissions(['clipboard-read', 'clipboard-write']); await page.goto('/'); + await expect(page.locator('.install-inline code')).toHaveText( + 'npm install @funsaized/stet@0.1.0', + ); await page.locator('.install-inline').click(); await expect .poll(() => page.evaluate(() => navigator.clipboard.readText())) diff --git a/website/tests/showcase.spec.ts b/website/tests/showcase.spec.ts index ed4bfa5..a3a7d6e 100644 --- a/website/tests/showcase.spec.ts +++ b/website/tests/showcase.spec.ts @@ -135,7 +135,7 @@ test('agent stages disclose a deterministic workflow and use the same working fi await expect(page.getByLabel('Type Fieldnotes to confirm')).toBeFocused(); }); -test('placement controls move label and note, and reset restores reproducible code', async ({ +test('playground snippets stay on released 0.1.0 options and reset restores code', async ({ page, }) => { await page.goto('/playground'); @@ -143,16 +143,9 @@ test('placement controls move label and note, and reset restores reproducible co await page.getByText('Placement & reproducibility', { exact: true }).click(); for (const kind of ['arrow', 'sticky']) { await page.getByRole('tab', { name: kind, exact: true }).click(); - await page.locator('.playground-preview').scrollIntoViewIfNeeded(); - const locator = page.locator(kind === 'arrow' ? '.stet-label' : '.stet-overlay--sticky'); - const before = await locator.getAttribute('style'); - const nudge = page.getByRole('slider', { name: 'Horizontal nudge' }); - await nudge.focus(); - await nudge.press('ArrowRight'); - await expect(locator).not.toHaveAttribute('style', before!); - await expect(page.locator('.mini-code pre')).toContainText( - kind === 'arrow' ? 'labelOffsetX:' : 'offsetX:', - ); + const snippet = await page.locator('.mini-code pre').innerText(); + expect(snippet).not.toMatch(/labelOffset|offsetX|offsetY/); + await expect(page.getByRole('slider', { name: 'Horizontal nudge' })).toHaveCount(0); } await page.getByRole('button', { name: 'Reset playground' }).click(); await expect(page.locator('.mini-code pre')).toHaveText(initial); @@ -285,7 +278,7 @@ test('workflow conversation follows stages with technical details expanded by de await expect(chat).toContainText('Agent · Skill:'); await expect(chat).toContainText('keep the existing warning'); await expect(code).toBeVisible(); - await expect(code).toContainText('npx stet agent init'); + await expect(code).toContainText('./node_modules/.bin/stet agent init'); await detailsToggle.click(); await expect(code).not.toBeVisible(); await detailsToggle.click(); diff --git a/website/tsconfig.json b/website/tsconfig.json index bd481e3..b7673d8 100644 --- a/website/tsconfig.json +++ b/website/tsconfig.json @@ -9,11 +9,7 @@ "skipLibCheck": true, "noEmit": true, "allowImportingTsExtensions": true, - "types": ["vite/client", "node"], - "paths": { - "@funsaized/stet": ["../src/index.ts"], - "@funsaized/stet/react": ["../src/react.ts"] - } + "types": ["vite/client", "node"] }, "include": ["src", "vite.config.ts", "playwright.config.ts", "tests"] } diff --git a/website/vite.config.ts b/website/vite.config.ts index f8439f8..f5a940f 100644 --- a/website/vite.config.ts +++ b/website/vite.config.ts @@ -1,5 +1,4 @@ import { defineConfig } from 'vite'; -import { fileURLToPath } from 'node:url'; import react from '@vitejs/plugin-react'; export default defineConfig({ @@ -7,19 +6,5 @@ export default defineConfig({ plugins: [react()], resolve: { dedupe: ['react', 'react-dom'], - alias: [ - { - find: '@funsaized/stet/react', - replacement: fileURLToPath(new URL('../src/react.ts', import.meta.url)), - }, - { - find: '@funsaized/stet/style.css', - replacement: fileURLToPath(new URL('../style.css', import.meta.url)), - }, - { - find: /^@funsaized\/stet$/, - replacement: fileURLToPath(new URL('../src/index.ts', import.meta.url)), - }, - ], }, }); From d6fd6e70bc7373cd1ca5bba1270ba6c96026e63e Mon Sep 17 00:00:00 2001 From: Sai Nimmagadda Date: Sun, 13 Sep 2026 01:03:13 -0400 Subject: [PATCH 05/11] feat: phase 3 --- agent/annotation-plan.d.ts | 24 + agent/capabilities.json | 96 ++ agent/catalog.mjs | 135 +- agent/schemas/annotation-plan.schema.json | 262 +++- agent/schemas/capabilities.schema.json | 96 ++ agent/validate.generated.mjs | 2 +- docs/delivery/backlog.md | 35 +- docs/delivery/evidence/CORE-01.md | 11 + docs/delivery/evidence/CORE-02.md | 11 + docs/delivery/evidence/CORE-03.md | 11 + docs/delivery/evidence/CORE-04.md | 11 + docs/delivery/evidence/CORE-05.md | 11 + docs/delivery/evidence/CORE-06.md | 11 + docs/delivery/evidence/CORE-07.md | 11 + docs/delivery/tasks/AD-ANGULAR.md | 2 +- docs/delivery/tasks/AD-REACT.md | 2 +- docs/delivery/tasks/AD-SVELTE.md | 2 +- docs/delivery/tasks/AD-VUE.md | 2 +- docs/delivery/tasks/BOX-01.md | 2 +- docs/delivery/tasks/CORE-01.md | 2 +- docs/delivery/tasks/CORE-02.md | 2 +- docs/delivery/tasks/CORE-03.md | 2 +- docs/delivery/tasks/CORE-04.md | 2 +- docs/delivery/tasks/CORE-05.md | 2 +- docs/delivery/tasks/CORE-06.md | 2 +- docs/delivery/tasks/CORE-07.md | 2 +- docs/delivery/tasks/WEB-01.md | 2 +- scripts/agent/generate.mjs | 414 +++++-- src/index.ts | 1 + src/mount.ts | 561 ++++++++- src/primitives.ts | 339 ++--- tests/agent/schema.test.ts | 211 +++- tests/browser/reveal.spec.ts | 440 +++++++ tests/browser/scroll.spec.ts | 153 ++- tests/stet.test.ts | 1379 ++++++++++++++++++++- 35 files changed, 3795 insertions(+), 456 deletions(-) create mode 100644 docs/delivery/evidence/CORE-01.md create mode 100644 docs/delivery/evidence/CORE-02.md create mode 100644 docs/delivery/evidence/CORE-03.md create mode 100644 docs/delivery/evidence/CORE-04.md create mode 100644 docs/delivery/evidence/CORE-05.md create mode 100644 docs/delivery/evidence/CORE-06.md create mode 100644 docs/delivery/evidence/CORE-07.md create mode 100644 tests/browser/reveal.spec.ts diff --git a/agent/annotation-plan.d.ts b/agent/annotation-plan.d.ts index 01a905b..50c102d 100644 --- a/agent/annotation-plan.d.ts +++ b/agent/annotation-plan.d.ts @@ -22,6 +22,10 @@ export type AnnotationPlan = { "width"?: number; "resketchOnHover"?: boolean; "padding"?: number; +"visible"?: boolean; +"animate"?: boolean; +"animationDuration"?: number; +"animationDelay"?: number; "description"?: string; }; } | { @@ -43,6 +47,10 @@ export type AnnotationPlan = { "width"?: number; "resketchOnHover"?: boolean; "padding"?: number; +"visible"?: boolean; +"animate"?: boolean; +"animationDuration"?: number; +"animationDelay"?: number; "description"?: string; }; } | { @@ -64,6 +72,10 @@ export type AnnotationPlan = { "width"?: number; "resketchOnHover"?: boolean; "padding"?: number; +"visible"?: boolean; +"animate"?: boolean; +"animationDuration"?: number; +"animationDelay"?: number; "description"?: string; }; } | { @@ -95,6 +107,10 @@ export type AnnotationPlan = { "width"?: number; "resketchOnHover"?: boolean; "padding"?: number; +"visible"?: boolean; +"animate"?: boolean; +"animationDuration"?: number; +"animationDelay"?: number; "description"?: string; }; } | { @@ -120,6 +136,10 @@ export type AnnotationPlan = { "width"?: number; "resketchOnHover"?: boolean; "padding"?: number; +"visible"?: boolean; +"animate"?: boolean; +"animationDuration"?: number; +"animationDelay"?: number; "description"?: string; }; } | { @@ -141,6 +161,10 @@ export type AnnotationPlan = { "width"?: number; "resketchOnHover"?: boolean; "padding"?: number; +"visible"?: boolean; +"animate"?: boolean; +"animationDuration"?: number; +"animationDelay"?: number; "description"?: string; }; "kind": "right" | "wrong"; diff --git a/agent/capabilities.json b/agent/capabilities.json index 381c554..5b5febf 100644 --- a/agent/capabilities.json +++ b/agent/capabilities.json @@ -130,6 +130,20 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" @@ -146,6 +160,10 @@ "width": "CSS token", "resketchOnHover": false, "padding": 5, + "visible": true, + "animate": false, + "animationDuration": 600, + "animationDelay": 0, "description": "absent" }, "effects": "fill has no visible effect" @@ -187,6 +205,20 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" @@ -203,6 +235,10 @@ "width": "CSS token", "resketchOnHover": false, "padding": 3, + "visible": true, + "animate": false, + "animationDuration": 600, + "animationDelay": 0, "description": "absent" }, "effects": "fill has no visible effect" @@ -244,6 +280,20 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" @@ -260,6 +310,7 @@ "width": "CSS token", "resketchOnHover": false, "padding": 5, + "visible": true, "description": "absent" }, "effects": "fill controls wash; stroke/width have no visible effect; padding positions the overlay" @@ -314,6 +365,20 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" @@ -333,6 +398,7 @@ "fill": "CSS token", "width": "CSS token", "resketchOnHover": false, + "visible": true, "description": "absent" }, "effects": "fill/padding have no visible effect; curvature is clamped to -0.8…0.8" @@ -394,6 +460,20 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" @@ -415,6 +495,7 @@ "width": "CSS token", "resketchOnHover": false, "padding": 14, + "visible": true, "description": "absent" }, "effects": "fill controls paper; stroke controls text; width has no visible effect" @@ -456,6 +537,20 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" @@ -472,6 +567,7 @@ "width": "CSS token", "resketchOnHover": false, "padding": 4, + "visible": true, "description": "absent" }, "kind": { diff --git a/agent/catalog.mjs b/agent/catalog.mjs index 9186b3d..11295e0 100644 --- a/agent/catalog.mjs +++ b/agent/catalog.mjs @@ -1,28 +1,121 @@ // Agent-only behavior metadata. Option types come from src/*.ts, identity from package.json. export const frameworks = { - vanilla: { extension: 'ts', subpath: '', shape: 'DOM attachers', lifecycle: 'Attach after DOM mount; destroy before removal; destroy and reattach to change options.' }, - react: { extension: 'tsx', subpath: '/react', shape: 'null-rendering components with target refs; Arrow uses from/to refs', lifecycle: 'Effects attach after commit and clean up; ref changes require a React commit.' }, - vue: { extension: 'vue', subpath: '/vue', shape: 'vStet directives; arrow host is from, binding.to is Element', lifecycle: 'mounted/updated/unmounted; mount arrow host only after destination exists.' }, - svelte: { extension: 'svelte', subpath: '/svelte', shape: 'actions; arrow host is from, options.to is Element', lifecycle: 'Action update/destroy; mount arrow host only after destination exists.' }, - angular: { extension: 'ts', subpath: '/angular', shape: 'standalone Stet*Directive; arrow host is from, input.to is Element', lifecycle: 'afterNextRender/ngOnChanges/ngOnDestroy; replace input option objects on update.' }, + vanilla: { + extension: "ts", + subpath: "", + shape: "DOM attachers", + lifecycle: + "Attach after DOM mount; destroy before removal; destroy and reattach to change options.", + }, + react: { + extension: "tsx", + subpath: "/react", + shape: "null-rendering components with target refs; Arrow uses from/to refs", + lifecycle: "Effects attach after commit and clean up; ref changes require a React commit.", + }, + vue: { + extension: "vue", + subpath: "/vue", + shape: "vStet directives; arrow host is from, binding.to is Element", + lifecycle: "mounted/updated/unmounted; mount arrow host only after destination exists.", + }, + svelte: { + extension: "svelte", + subpath: "/svelte", + shape: "actions; arrow host is from, options.to is Element", + lifecycle: "Action update/destroy; mount arrow host only after destination exists.", + }, + angular: { + extension: "ts", + subpath: "/angular", + shape: "standalone Stet*Directive; arrow host is from, input.to is Element", + lifecycle: "afterNextRender/ngOnChanges/ngOnDestroy; replace input option objects on update.", + }, }; export const primitives = { - circle: { type: 'StetOptions', targets: ['target'], padding: 5, purpose: 'Emphasize an existing element', meaningfulText: 'description' }, - underline: { type: 'StetOptions', targets: ['target'], padding: 3, purpose: 'Emphasize words, following wrapped text', meaningfulText: 'description' }, - highlight: { type: 'StetOptions', targets: ['target'], padding: 5, purpose: 'Marker wash following wrapped text', meaningfulText: 'description' }, - arrow: { type: 'ArrowOptions', targets: ['from', 'to'], purpose: 'Connect two existing elements', meaningfulText: 'label or description; describes to' }, - sticky: { type: 'StickyOptions', targets: ['target'], padding: 14, purpose: 'Explain with concise paper-note text', meaningfulText: 'text; describes target' }, - mark: { type: 'StetOptions', targets: ['target'], padding: 4, purpose: 'Check right or cross wrong; kind is a separate core argument', meaningfulText: 'description' }, + circle: { + type: "StetOptions", + targets: ["target"], + padding: 5, + purpose: "Emphasize an existing element", + meaningfulText: "description", + }, + underline: { + type: "StetOptions", + targets: ["target"], + padding: 3, + purpose: "Emphasize words, following wrapped text", + meaningfulText: "description", + }, + highlight: { + type: "StetOptions", + targets: ["target"], + padding: 5, + purpose: "Marker wash following wrapped text", + meaningfulText: "description", + }, + arrow: { + type: "ArrowOptions", + targets: ["from", "to"], + purpose: "Connect two existing elements", + meaningfulText: "label or description; describes to", + }, + sticky: { + type: "StickyOptions", + targets: ["target"], + padding: 14, + purpose: "Explain with concise paper-note text", + meaningfulText: "text; describes target", + }, + mark: { + type: "StetOptions", + targets: ["target"], + padding: 4, + purpose: "Check right or cross wrong; kind is a separate core argument", + meaningfulText: "description", + }, +}; +export const defaults = { + seed: "random uint32", + roughness: 1, + boil: 0, + stroke: "CSS token", + fill: "CSS token", + width: "CSS token", + resketchOnHover: false, + visible: true, + animate: false, + animationDuration: 600, + animationDelay: 0, + description: "absent", + label: "absent", + curvature: 0.16, + side: "auto", + offsetX: 0, + offsetY: 0, + labelOffsetX: 0, + labelOffsetY: 0, +}; +export const strategies = ["ref", "id", "data-attribute", "source", "stet-attribute", "css"]; +export const tools = { + claude: ".claude/skills", + cursor: ".agents/skills", + opencode: ".agents/skills", + codex: ".agents/skills", }; -export const defaults = { seed: 'random uint32', roughness: 1, boil: 0, stroke: 'CSS token', fill: 'CSS token', width: 'CSS token', resketchOnHover: false, description: 'absent', label: 'absent', curvature: 0.16, side: 'auto', offsetX: 0, offsetY: 0, labelOffsetX: 0, labelOffsetY: 0 }; -export const strategies = ['ref', 'id', 'data-attribute', 'source', 'stet-attribute', 'css']; -export const tools = { claude: '.claude/skills', cursor: '.agents/skills', opencode: '.agents/skills', codex: '.agents/skills' }; export const constraints = { - ownership: 'Existing UI owns layout, semantics, focus, pointer interaction, forms and lifecycle. Annotations never replace controls.', - accessibility: 'SVG is decorative. Supply description for meaningful marks. Sticky text and arrow labels are DOM text linked by aria-describedby. Do not use annotations as the only persistent safety warning.', - motion: 'Still by default; reduced motion disables boil and hover resketch live. Fixed seed, dimensions and options reproduce geometry.', - lifecycle: 'DOM Elements in the current document and document.body must exist. Options are snapshots. Handles expose refresh(), resketch(seed?), destroy().', - targeting: 'Targets are source-editing evidence, not executable selectors. Prove uniqueness and readiness in application code. Arrow order is from, to.', - placement: 'Body overlays; resize/scroll/font tracking. Movement without resize needs refresh(). No collision engine, cross-document targets, top-layer dialogs or transformed/zoomed body/html support. Sticky offsetX/offsetY and arrow labelOffsetX/labelOffsetY nudge automatic placement before viewport clamping; they do not avoid obstacles. Partial clipping and very long notes need visual review.', - styling: 'Import @funsaized/stet/style.css once; preserve target layout and semantics. CSS variables theme the overlay.', + ownership: + "Existing UI owns layout, semantics, focus, pointer interaction, forms and lifecycle. Annotations never replace controls.", + accessibility: + "SVG is decorative. Supply description for meaningful marks. Sticky text and arrow labels are DOM text linked by aria-describedby. Do not use annotations as the only persistent safety warning.", + motion: + "Still by default; reduced motion disables boil and hover resketch live. Fixed seed, dimensions and options reproduce geometry.", + lifecycle: + "DOM Elements in the current document and document.body must exist. Options are snapshots. Handles expose refresh(), resketch(seed?), destroy().", + targeting: + "Targets are source-editing evidence, not executable selectors. Prove uniqueness and readiness in application code. Arrow order is from, to.", + placement: + "Body overlays; resize/scroll/font tracking. Movement without resize needs refresh(). No collision engine, cross-document targets, top-layer dialogs or transformed/zoomed body/html support. Sticky offsetX/offsetY and arrow labelOffsetX/labelOffsetY nudge automatic placement before viewport clamping; they do not avoid obstacles. Partial clipping and very long notes need visual review.", + styling: + "Import @funsaized/stet/style.css once; preserve target layout and semantics. CSS variables theme the overlay.", }; diff --git a/agent/schemas/annotation-plan.schema.json b/agent/schemas/annotation-plan.schema.json index 83a6f72..76c8641 100644 --- a/agent/schemas/annotation-plan.schema.json +++ b/agent/schemas/annotation-plan.schema.json @@ -136,6 +136,20 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" @@ -259,6 +273,20 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" @@ -382,12 +410,69 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" } }, - "required": [] + "required": [], + "allOf": [ + { + "properties": { + "animate": { + "not": { + "const": true + } + } + } + }, + { + "if": { + "anyOf": [ + { + "required": [ + "animationDuration" + ], + "properties": { + "animationDuration": true + } + }, + { + "required": [ + "animationDelay" + ], + "properties": { + "animationDelay": true + } + } + ] + }, + "then": { + "properties": { + "animate": { + "const": false + } + }, + "required": [ + "animate" + ] + } + } + ] } }, "required": [ @@ -518,12 +603,69 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" } }, - "required": [] + "required": [], + "allOf": [ + { + "properties": { + "animate": { + "not": { + "const": true + } + } + } + }, + { + "if": { + "anyOf": [ + { + "required": [ + "animationDuration" + ], + "properties": { + "animationDuration": true + } + }, + { + "required": [ + "animationDelay" + ], + "properties": { + "animationDelay": true + } + } + ] + }, + "then": { + "properties": { + "animate": { + "const": false + } + }, + "required": [ + "animate" + ] + } + } + ] } }, "required": [ @@ -661,6 +803,20 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" @@ -668,6 +824,49 @@ }, "required": [ "text" + ], + "allOf": [ + { + "properties": { + "animate": { + "not": { + "const": true + } + } + } + }, + { + "if": { + "anyOf": [ + { + "required": [ + "animationDuration" + ], + "properties": { + "animationDuration": true + } + }, + { + "required": [ + "animationDelay" + ], + "properties": { + "animationDelay": true + } + } + ] + }, + "then": { + "properties": { + "animate": { + "const": false + } + }, + "required": [ + "animate" + ] + } + } ] } }, @@ -787,12 +986,69 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" } }, - "required": [] + "required": [], + "allOf": [ + { + "properties": { + "animate": { + "not": { + "const": true + } + } + } + }, + { + "if": { + "anyOf": [ + { + "required": [ + "animationDuration" + ], + "properties": { + "animationDuration": true + } + }, + { + "required": [ + "animationDelay" + ], + "properties": { + "animationDelay": true + } + } + ] + }, + "then": { + "properties": { + "animate": { + "const": false + } + }, + "required": [ + "animate" + ] + } + } + ] }, "kind": { "type": "string", diff --git a/agent/schemas/capabilities.schema.json b/agent/schemas/capabilities.schema.json index dd83c8b..feda719 100644 --- a/agent/schemas/capabilities.schema.json +++ b/agent/schemas/capabilities.schema.json @@ -134,6 +134,20 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" @@ -150,6 +164,10 @@ "width": "CSS token", "resketchOnHover": false, "padding": 5, + "visible": true, + "animate": false, + "animationDuration": 600, + "animationDelay": 0, "description": "absent" }, "effects": "fill has no visible effect" @@ -191,6 +209,20 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" @@ -207,6 +239,10 @@ "width": "CSS token", "resketchOnHover": false, "padding": 3, + "visible": true, + "animate": false, + "animationDuration": 600, + "animationDelay": 0, "description": "absent" }, "effects": "fill has no visible effect" @@ -248,6 +284,20 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" @@ -264,6 +314,7 @@ "width": "CSS token", "resketchOnHover": false, "padding": 5, + "visible": true, "description": "absent" }, "effects": "fill controls wash; stroke/width have no visible effect; padding positions the overlay" @@ -318,6 +369,20 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" @@ -337,6 +402,7 @@ "fill": "CSS token", "width": "CSS token", "resketchOnHover": false, + "visible": true, "description": "absent" }, "effects": "fill/padding have no visible effect; curvature is clamped to -0.8…0.8" @@ -398,6 +464,20 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" @@ -419,6 +499,7 @@ "width": "CSS token", "resketchOnHover": false, "padding": 14, + "visible": true, "description": "absent" }, "effects": "fill controls paper; stroke controls text; width has no visible effect" @@ -460,6 +541,20 @@ "padding": { "type": "number" }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, "description": { "type": "string", "pattern": "\\S" @@ -476,6 +571,7 @@ "width": "CSS token", "resketchOnHover": false, "padding": 4, + "visible": true, "description": "absent" }, "kind": { diff --git a/agent/validate.generated.mjs b/agent/validate.generated.mjs index 2d56088..790db33 100644 --- a/agent/validate.generated.mjs +++ b/agent/validate.generated.mjs @@ -1,2 +1,2 @@ // Generated by Ajv (MIT); see LICENSE-ajv.txt. Do not edit. -"use strict";export const validate = validate10;export default validate10;const schema11 = {"$schema":"http://json-schema.org/draft-07/schema#","title":"Stet Annotation Plan v1","type":"object","additionalProperties":false,"properties":{"version":{"type":"integer","const":1},"framework":{"type":"string","enum":["vanilla","react","vue","svelte","angular"]},"intent":{"type":"string","pattern":"\\S"},"annotations":{"type":"array","minItems":1,"items":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"circle","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":1,"maxItems":1},"options":{"type":"object","additionalProperties":false,"properties":{"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"description":{"type":"string","pattern":"\\S"}},"required":[]}},"required":["id","primitive","targets"]},{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"underline","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":1,"maxItems":1},"options":{"type":"object","additionalProperties":false,"properties":{"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"description":{"type":"string","pattern":"\\S"}},"required":[]}},"required":["id","primitive","targets"]},{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"highlight","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":1,"maxItems":1},"options":{"type":"object","additionalProperties":false,"properties":{"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"description":{"type":"string","pattern":"\\S"}},"required":[]}},"required":["id","primitive","targets"]},{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"arrow","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":2,"maxItems":2},"options":{"type":"object","additionalProperties":false,"properties":{"label":{"type":"string","pattern":"\\S"},"curvature":{"type":"number"},"labelOffsetX":{"type":"number"},"labelOffsetY":{"type":"number"},"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"description":{"type":"string","pattern":"\\S"}},"required":[]}},"required":["id","primitive","targets"]},{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"sticky","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":1,"maxItems":1},"options":{"type":"object","additionalProperties":false,"properties":{"text":{"type":"string","pattern":"\\S"},"side":{"type":"string","enum":["auto","top","right","bottom","left"]},"offsetX":{"type":"number"},"offsetY":{"type":"number"},"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"description":{"type":"string","pattern":"\\S"}},"required":["text"]}},"required":["id","primitive","targets","options"]},{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"mark","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":1,"maxItems":1},"options":{"type":"object","additionalProperties":false,"properties":{"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"description":{"type":"string","pattern":"\\S"}},"required":[]},"kind":{"type":"string","enum":["right","wrong"]}},"required":["id","primitive","targets","kind"]}]}}},"required":["version","framework","intent","annotations"]};const pattern0 = new RegExp("\\S", "u");const pattern1 = new RegExp("^[a-zA-Z0-9][a-zA-Z0-9._-]*$", "u");const func2 = Object.prototype.hasOwnProperty;function validate10(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;if(data && typeof data == "object" && !Array.isArray(data)){if(data.version === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "version"},message:"must have required property '"+"version"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.framework === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "framework"},message:"must have required property '"+"framework"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.intent === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "intent"},message:"must have required property '"+"intent"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.annotations === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "annotations"},message:"must have required property '"+"annotations"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}for(const key0 in data){if(!((((key0 === "version") || (key0 === "framework")) || (key0 === "intent")) || (key0 === "annotations"))){const err4 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}if(data.version !== undefined){let data0 = data.version;if(!(((typeof data0 == "number") && (!(data0 % 1) && !isNaN(data0))) && (isFinite(data0)))){const err5 = {instancePath:instancePath+"/version",schemaPath:"#/properties/version/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(1 !== data0){const err6 = {instancePath:instancePath+"/version",schemaPath:"#/properties/version/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.framework !== undefined){let data1 = data.framework;if(typeof data1 !== "string"){const err7 = {instancePath:instancePath+"/framework",schemaPath:"#/properties/framework/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(!(((((data1 === "vanilla") || (data1 === "react")) || (data1 === "vue")) || (data1 === "svelte")) || (data1 === "angular"))){const err8 = {instancePath:instancePath+"/framework",schemaPath:"#/properties/framework/enum",keyword:"enum",params:{allowedValues: schema11.properties.framework.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.intent !== undefined){let data2 = data.intent;if(typeof data2 === "string"){if(!pattern0.test(data2)){const err9 = {instancePath:instancePath+"/intent",schemaPath:"#/properties/intent/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/intent",schemaPath:"#/properties/intent/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.annotations !== undefined){let data3 = data.annotations;if(Array.isArray(data3)){if(data3.length < 1){const err11 = {instancePath:instancePath+"/annotations",schemaPath:"#/properties/annotations/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}const len0 = data3.length;for(let i0=0; i0 1){const err20 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/0/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(data7.length < 1){const err21 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/0/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}const len1 = data7.length;for(let i1=0; i1 1){const err66 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/1/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}if(data28.length < 1){const err67 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/1/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}const len2 = data28.length;for(let i2=0; i2 1){const err112 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/2/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err112];}else {vErrors.push(err112);}errors++;}if(data49.length < 1){const err113 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/2/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err113];}else {vErrors.push(err113);}errors++;}const len3 = data49.length;for(let i3=0; i3 2){const err158 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/3/properties/targets/maxItems",keyword:"maxItems",params:{limit: 2},message:"must NOT have more than 2 items"};if(vErrors === null){vErrors = [err158];}else {vErrors.push(err158);}errors++;}if(data70.length < 2){const err159 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/3/properties/targets/minItems",keyword:"minItems",params:{limit: 2},message:"must NOT have fewer than 2 items"};if(vErrors === null){vErrors = [err159];}else {vErrors.push(err159);}errors++;}const len4 = data70.length;for(let i4=0; i4 1){const err210 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/4/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err210];}else {vErrors.push(err210);}errors++;}if(data95.length < 1){const err211 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/4/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err211];}else {vErrors.push(err211);}errors++;}const len5 = data95.length;for(let i5=0; i5 1){const err264 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/5/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err264];}else {vErrors.push(err264);}errors++;}if(data120.length < 1){const err265 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/5/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err265];}else {vErrors.push(err265);}errors++;}const len6 = data120.length;for(let i6=0; i6 1){const err20 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/0/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(data7.length < 1){const err21 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/0/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}const len1 = data7.length;for(let i1=0; i1=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}}else {const err57 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}}if(data16.animationDelay !== undefined){let data28 = data16.animationDelay;if((typeof data28 == "number") && (isFinite(data28))){if(data28 < 0 || isNaN(data28)){const err58 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}}else {const err59 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}if(data16.description !== undefined){let data29 = data16.description;if(typeof data29 === "string"){if(!pattern0.test(data29)){const err60 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}}else {const err61 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}}}else {const err62 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}}}else {const err63 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/0/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}var _valid0 = _errs12 === errors;if(_valid0){valid3 = true;passing0 = 0;}const _errs70 = errors;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err64 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/1/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}if(data4.primitive === undefined){const err65 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/1/required",keyword:"required",params:{missingProperty: "primitive"},message:"must have required property '"+"primitive"+"'"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}if(data4.targets === undefined){const err66 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/1/required",keyword:"required",params:{missingProperty: "targets"},message:"must have required property '"+"targets"+"'"};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}for(const key4 in data4){if(!((((key4 === "id") || (key4 === "primitive")) || (key4 === "targets")) || (key4 === "options"))){const err67 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/1/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key4},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}}if(data4.id !== undefined){let data30 = data4.id;if(typeof data30 === "string"){if(!pattern1.test(data30)){const err68 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/1/properties/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},message:"must match pattern \""+"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"+"\""};if(vErrors === null){vErrors = [err68];}else {vErrors.push(err68);}errors++;}}else {const err69 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/1/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}}if(data4.primitive !== undefined){let data31 = data4.primitive;if(typeof data31 !== "string"){const err70 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/1/properties/primitive/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}if("underline" !== data31){const err71 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/1/properties/primitive/const",keyword:"const",params:{allowedValue: "underline"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}}if(data4.targets !== undefined){let data32 = data4.targets;if(Array.isArray(data32)){if(data32.length > 1){const err72 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/1/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}if(data32.length < 1){const err73 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/1/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err73];}else {vErrors.push(err73);}errors++;}const len2 = data32.length;for(let i2=0; i2=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err108];}else {vErrors.push(err108);}errors++;}}else {const err109 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err109];}else {vErrors.push(err109);}errors++;}}if(data41.animationDelay !== undefined){let data53 = data41.animationDelay;if((typeof data53 == "number") && (isFinite(data53))){if(data53 < 0 || isNaN(data53)){const err110 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err110];}else {vErrors.push(err110);}errors++;}}else {const err111 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err111];}else {vErrors.push(err111);}errors++;}}if(data41.description !== undefined){let data54 = data41.description;if(typeof data54 === "string"){if(!pattern0.test(data54)){const err112 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err112];}else {vErrors.push(err112);}errors++;}}else {const err113 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err113];}else {vErrors.push(err113);}errors++;}}}else {const err114 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err114];}else {vErrors.push(err114);}errors++;}}}else {const err115 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err115];}else {vErrors.push(err115);}errors++;}var _valid0 = _errs70 === errors;if(_valid0 && valid3){valid3 = false;passing0 = [passing0, 1];}else {if(_valid0){valid3 = true;passing0 = 1;}const _errs128 = errors;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err116 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/2/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err116];}else {vErrors.push(err116);}errors++;}if(data4.primitive === undefined){const err117 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/2/required",keyword:"required",params:{missingProperty: "primitive"},message:"must have required property '"+"primitive"+"'"};if(vErrors === null){vErrors = [err117];}else {vErrors.push(err117);}errors++;}if(data4.targets === undefined){const err118 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/2/required",keyword:"required",params:{missingProperty: "targets"},message:"must have required property '"+"targets"+"'"};if(vErrors === null){vErrors = [err118];}else {vErrors.push(err118);}errors++;}for(const key7 in data4){if(!((((key7 === "id") || (key7 === "primitive")) || (key7 === "targets")) || (key7 === "options"))){const err119 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/2/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key7},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err119];}else {vErrors.push(err119);}errors++;}}if(data4.id !== undefined){let data55 = data4.id;if(typeof data55 === "string"){if(!pattern1.test(data55)){const err120 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/2/properties/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},message:"must match pattern \""+"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"+"\""};if(vErrors === null){vErrors = [err120];}else {vErrors.push(err120);}errors++;}}else {const err121 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/2/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err121];}else {vErrors.push(err121);}errors++;}}if(data4.primitive !== undefined){let data56 = data4.primitive;if(typeof data56 !== "string"){const err122 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/2/properties/primitive/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err122];}else {vErrors.push(err122);}errors++;}if("highlight" !== data56){const err123 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/2/properties/primitive/const",keyword:"const",params:{allowedValue: "highlight"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err123];}else {vErrors.push(err123);}errors++;}}if(data4.targets !== undefined){let data57 = data4.targets;if(Array.isArray(data57)){if(data57.length > 1){const err124 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/2/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err124];}else {vErrors.push(err124);}errors++;}if(data57.length < 1){const err125 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/2/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err125];}else {vErrors.push(err125);}errors++;}const len3 = data57.length;for(let i3=0; i3=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err168];}else {vErrors.push(err168);}errors++;}}else {const err169 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err169];}else {vErrors.push(err169);}errors++;}}if(data66.animationDelay !== undefined){let data80 = data66.animationDelay;if((typeof data80 == "number") && (isFinite(data80))){if(data80 < 0 || isNaN(data80)){const err170 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err170];}else {vErrors.push(err170);}errors++;}}else {const err171 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err171];}else {vErrors.push(err171);}errors++;}}if(data66.description !== undefined){let data81 = data66.description;if(typeof data81 === "string"){if(!pattern0.test(data81)){const err172 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err172];}else {vErrors.push(err172);}errors++;}}else {const err173 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err173];}else {vErrors.push(err173);}errors++;}}}else {const err174 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err174];}else {vErrors.push(err174);}errors++;}}}else {const err175 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/2/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err175];}else {vErrors.push(err175);}errors++;}var _valid0 = _errs128 === errors;if(_valid0 && valid3){valid3 = false;passing0 = [passing0, 2];}else {if(_valid0){valid3 = true;passing0 = 2;}const _errs198 = errors;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err176 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/3/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err176];}else {vErrors.push(err176);}errors++;}if(data4.primitive === undefined){const err177 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/3/required",keyword:"required",params:{missingProperty: "primitive"},message:"must have required property '"+"primitive"+"'"};if(vErrors === null){vErrors = [err177];}else {vErrors.push(err177);}errors++;}if(data4.targets === undefined){const err178 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/3/required",keyword:"required",params:{missingProperty: "targets"},message:"must have required property '"+"targets"+"'"};if(vErrors === null){vErrors = [err178];}else {vErrors.push(err178);}errors++;}for(const key10 in data4){if(!((((key10 === "id") || (key10 === "primitive")) || (key10 === "targets")) || (key10 === "options"))){const err179 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/3/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key10},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err179];}else {vErrors.push(err179);}errors++;}}if(data4.id !== undefined){let data82 = data4.id;if(typeof data82 === "string"){if(!pattern1.test(data82)){const err180 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/3/properties/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},message:"must match pattern \""+"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"+"\""};if(vErrors === null){vErrors = [err180];}else {vErrors.push(err180);}errors++;}}else {const err181 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/3/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err181];}else {vErrors.push(err181);}errors++;}}if(data4.primitive !== undefined){let data83 = data4.primitive;if(typeof data83 !== "string"){const err182 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/3/properties/primitive/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err182];}else {vErrors.push(err182);}errors++;}if("arrow" !== data83){const err183 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/3/properties/primitive/const",keyword:"const",params:{allowedValue: "arrow"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err183];}else {vErrors.push(err183);}errors++;}}if(data4.targets !== undefined){let data84 = data4.targets;if(Array.isArray(data84)){if(data84.length > 2){const err184 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/3/properties/targets/maxItems",keyword:"maxItems",params:{limit: 2},message:"must NOT have more than 2 items"};if(vErrors === null){vErrors = [err184];}else {vErrors.push(err184);}errors++;}if(data84.length < 2){const err185 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/3/properties/targets/minItems",keyword:"minItems",params:{limit: 2},message:"must NOT have fewer than 2 items"};if(vErrors === null){vErrors = [err185];}else {vErrors.push(err185);}errors++;}const len4 = data84.length;for(let i4=0; i4=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err233];}else {vErrors.push(err233);}errors++;}}else {const err234 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err234];}else {vErrors.push(err234);}errors++;}}if(data93.animationDelay !== undefined){let data111 = data93.animationDelay;if((typeof data111 == "number") && (isFinite(data111))){if(data111 < 0 || isNaN(data111)){const err235 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err235];}else {vErrors.push(err235);}errors++;}}else {const err236 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err236];}else {vErrors.push(err236);}errors++;}}if(data93.description !== undefined){let data112 = data93.description;if(typeof data112 === "string"){if(!pattern0.test(data112)){const err237 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err237];}else {vErrors.push(err237);}errors++;}}else {const err238 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err238];}else {vErrors.push(err238);}errors++;}}}else {const err239 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err239];}else {vErrors.push(err239);}errors++;}}}else {const err240 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/3/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err240];}else {vErrors.push(err240);}errors++;}var _valid0 = _errs198 === errors;if(_valid0 && valid3){valid3 = false;passing0 = [passing0, 3];}else {if(_valid0){valid3 = true;passing0 = 3;}const _errs276 = errors;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err241 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err241];}else {vErrors.push(err241);}errors++;}if(data4.primitive === undefined){const err242 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/required",keyword:"required",params:{missingProperty: "primitive"},message:"must have required property '"+"primitive"+"'"};if(vErrors === null){vErrors = [err242];}else {vErrors.push(err242);}errors++;}if(data4.targets === undefined){const err243 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/required",keyword:"required",params:{missingProperty: "targets"},message:"must have required property '"+"targets"+"'"};if(vErrors === null){vErrors = [err243];}else {vErrors.push(err243);}errors++;}if(data4.options === undefined){const err244 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/required",keyword:"required",params:{missingProperty: "options"},message:"must have required property '"+"options"+"'"};if(vErrors === null){vErrors = [err244];}else {vErrors.push(err244);}errors++;}for(const key13 in data4){if(!((((key13 === "id") || (key13 === "primitive")) || (key13 === "targets")) || (key13 === "options"))){const err245 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key13},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err245];}else {vErrors.push(err245);}errors++;}}if(data4.id !== undefined){let data113 = data4.id;if(typeof data113 === "string"){if(!pattern1.test(data113)){const err246 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/4/properties/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},message:"must match pattern \""+"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"+"\""};if(vErrors === null){vErrors = [err246];}else {vErrors.push(err246);}errors++;}}else {const err247 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/4/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err247];}else {vErrors.push(err247);}errors++;}}if(data4.primitive !== undefined){let data114 = data4.primitive;if(typeof data114 !== "string"){const err248 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/4/properties/primitive/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err248];}else {vErrors.push(err248);}errors++;}if("sticky" !== data114){const err249 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/4/properties/primitive/const",keyword:"const",params:{allowedValue: "sticky"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err249];}else {vErrors.push(err249);}errors++;}}if(data4.targets !== undefined){let data115 = data4.targets;if(Array.isArray(data115)){if(data115.length > 1){const err250 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/4/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err250];}else {vErrors.push(err250);}errors++;}if(data115.length < 1){const err251 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/4/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err251];}else {vErrors.push(err251);}errors++;}const len5 = data115.length;for(let i5=0; i5=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err301];}else {vErrors.push(err301);}errors++;}}else {const err302 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err302];}else {vErrors.push(err302);}errors++;}}if(data124.animationDelay !== undefined){let data142 = data124.animationDelay;if((typeof data142 == "number") && (isFinite(data142))){if(data142 < 0 || isNaN(data142)){const err303 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err303];}else {vErrors.push(err303);}errors++;}}else {const err304 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err304];}else {vErrors.push(err304);}errors++;}}if(data124.description !== undefined){let data143 = data124.description;if(typeof data143 === "string"){if(!pattern0.test(data143)){const err305 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err305];}else {vErrors.push(err305);}errors++;}}else {const err306 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err306];}else {vErrors.push(err306);}errors++;}}}else {const err307 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err307];}else {vErrors.push(err307);}errors++;}}}else {const err308 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err308];}else {vErrors.push(err308);}errors++;}var _valid0 = _errs276 === errors;if(_valid0 && valid3){valid3 = false;passing0 = [passing0, 4];}else {if(_valid0){valid3 = true;passing0 = 4;}const _errs354 = errors;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err309 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err309];}else {vErrors.push(err309);}errors++;}if(data4.primitive === undefined){const err310 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/required",keyword:"required",params:{missingProperty: "primitive"},message:"must have required property '"+"primitive"+"'"};if(vErrors === null){vErrors = [err310];}else {vErrors.push(err310);}errors++;}if(data4.targets === undefined){const err311 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/required",keyword:"required",params:{missingProperty: "targets"},message:"must have required property '"+"targets"+"'"};if(vErrors === null){vErrors = [err311];}else {vErrors.push(err311);}errors++;}if(data4.kind === undefined){const err312 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err312];}else {vErrors.push(err312);}errors++;}for(const key16 in data4){if(!(((((key16 === "id") || (key16 === "primitive")) || (key16 === "targets")) || (key16 === "options")) || (key16 === "kind"))){const err313 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key16},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err313];}else {vErrors.push(err313);}errors++;}}if(data4.id !== undefined){let data144 = data4.id;if(typeof data144 === "string"){if(!pattern1.test(data144)){const err314 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/5/properties/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},message:"must match pattern \""+"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"+"\""};if(vErrors === null){vErrors = [err314];}else {vErrors.push(err314);}errors++;}}else {const err315 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/5/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err315];}else {vErrors.push(err315);}errors++;}}if(data4.primitive !== undefined){let data145 = data4.primitive;if(typeof data145 !== "string"){const err316 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/5/properties/primitive/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err316];}else {vErrors.push(err316);}errors++;}if("mark" !== data145){const err317 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/5/properties/primitive/const",keyword:"const",params:{allowedValue: "mark"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err317];}else {vErrors.push(err317);}errors++;}}if(data4.targets !== undefined){let data146 = data4.targets;if(Array.isArray(data146)){if(data146.length > 1){const err318 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/5/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err318];}else {vErrors.push(err318);}errors++;}if(data146.length < 1){const err319 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/5/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err319];}else {vErrors.push(err319);}errors++;}const len6 = data146.length;for(let i6=0; i6=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err362];}else {vErrors.push(err362);}errors++;}}else {const err363 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err363];}else {vErrors.push(err363);}errors++;}}if(data155.animationDelay !== undefined){let data169 = data155.animationDelay;if((typeof data169 == "number") && (isFinite(data169))){if(data169 < 0 || isNaN(data169)){const err364 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err364];}else {vErrors.push(err364);}errors++;}}else {const err365 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err365];}else {vErrors.push(err365);}errors++;}}if(data155.description !== undefined){let data170 = data155.description;if(typeof data170 === "string"){if(!pattern0.test(data170)){const err366 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err366];}else {vErrors.push(err366);}errors++;}}else {const err367 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err367];}else {vErrors.push(err367);}errors++;}}}else {const err368 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err368];}else {vErrors.push(err368);}errors++;}}if(data4.kind !== undefined){let data171 = data4.kind;if(typeof data171 !== "string"){const err369 = {instancePath:instancePath+"/annotations/" + i0+"/kind",schemaPath:"#/properties/annotations/items/oneOf/5/properties/kind/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err369];}else {vErrors.push(err369);}errors++;}if(!((data171 === "right") || (data171 === "wrong"))){const err370 = {instancePath:instancePath+"/annotations/" + i0+"/kind",schemaPath:"#/properties/annotations/items/oneOf/5/properties/kind/enum",keyword:"enum",params:{allowedValues: schema11.properties.annotations.items.oneOf[5].properties.kind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err370];}else {vErrors.push(err370);}errors++;}}}else {const err371 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err371];}else {vErrors.push(err371);}errors++;}var _valid0 = _errs354 === errors;if(_valid0 && valid3){valid3 = false;passing0 = [passing0, 5];}else {if(_valid0){valid3 = true;passing0 = 5;}}}}}}if(!valid3){const err372 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err372];}else {vErrors.push(err372);}errors++;}else {errors = _errs11;if(vErrors !== null){if(_errs11){vErrors.length = _errs11;}else {vErrors = null;}}}}}else {const err373 = {instancePath:instancePath+"/annotations",schemaPath:"#/properties/annotations/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err373];}else {vErrors.push(err373);}errors++;}}}else {const err374 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err374];}else {vErrors.push(err374);}errors++;}validate10.errors = vErrors;return errors === 0;} diff --git a/docs/delivery/backlog.md b/docs/delivery/backlog.md index fb94840..b8acc6f 100644 --- a/docs/delivery/backlog.md +++ b/docs/delivery/backlog.md @@ -13,20 +13,20 @@ See [execution rules](README.md) and the approved [contracts](contracts/). | FIX-01 | Required | A1 | BASE-01 | ACCEPTED | | FIX-02 | Required | A6 | FIX-01 | ACCEPTED | | MEASURE-01 | Required | A5 | BASE-01 | ACCEPTED | -| CORE-01 | Required | B1 | CONTRACT-01 | READY | -| CORE-02 | Required | B2/F1 | CORE-01 | BLOCKED | -| CORE-03 | Required | B2 | CORE-02 | BLOCKED | -| CORE-04 | Required | B2 | CORE-03 | BLOCKED | -| CORE-05 | Required | B4 | CORE-04 | BLOCKED | -| CORE-06 | Required | B5 | CORE-05 | BLOCKED | -| CORE-07 | Required | Required internal B6 subset | CONTRACT-02, CORE-06 | BLOCKED | +| CORE-01 | Required | B1 | CONTRACT-01 | ACCEPTED | +| CORE-02 | Required | B2/F1 | CORE-01 | ACCEPTED | +| CORE-03 | Required | B2 | CORE-02 | ACCEPTED | +| CORE-04 | Required | B2 | CORE-03 | ACCEPTED | +| CORE-05 | Required | B4 | CORE-04 | ACCEPTED | +| CORE-06 | Required | B5 | CORE-05 | ACCEPTED | +| CORE-07 | Required | Required internal B6 subset | CONTRACT-02, CORE-06 | ACCEPTED | | CORE-08 | Required | A5 | MEASURE-01, CORE-06, BOX-02 | BLOCKED | -| AD-REACT | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | BLOCKED | -| AD-VUE | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | BLOCKED | -| AD-SVELTE | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | BLOCKED | -| AD-ANGULAR | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | BLOCKED | +| AD-REACT | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | READY | +| AD-VUE | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | READY | +| AD-SVELTE | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | READY | +| AD-ANGULAR | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | READY | | AD-INTEGRATE | Required | B7/F2 | All AD tasks | BLOCKED | -| BOX-01 | Required | C1 | CORE-06 | BLOCKED | +| BOX-01 | Required | C1 | CORE-06 | READY | | BOX-02 | Required | C1/F1 | BOX-01, AD-INTEGRATE | BLOCKED | | PW-01 | Required gate | D1/D2 | CONTRACT-03 | READY | | PW-02 | Required gate | D1/D2/D3 | PW-01, CORE-05 | BLOCKED | @@ -43,7 +43,7 @@ See [execution rules](README.md) and the approved [contracts](contracts/). | AGENT-02 | Required | F2 | AD-INTEGRATE, CORE-05, BOX-02 | BLOCKED | | AGENT-03 | Required | F3 | PW-06, SHIP-03 | BLOCKED | | HANDOFF-01 | Required | D7 | PW-07, PW-08, AGENT-03 | BLOCKED | -| WEB-01 | Required | G1 | FIX-01, CORE-06 | BLOCKED | +| WEB-01 | Required | G1 | FIX-01, CORE-06 | READY | | WEB-02 | Required | G2 | FIX-02, AGENT-02, HANDOFF-01, WEB-01 | BLOCKED | | DOCS-01 | Required | A2/G4 | BOX-02, PW-06, SHIP-03 | BLOCKED | | RELEASE-01 | Required gate | H2 | CONTRACT-05, FIX-01..02, MEASURE-01, CORE-01..08, all AD, BOX-01..02, PW-01..08, SHIP-01..03, AGENT-01..03, HANDOFF-01, WEB-01..02, DOCS-01 | BLOCKED | @@ -53,10 +53,11 @@ See [execution rules](README.md) and the approved [contracts](contracts/). ## Next dispatch -`FIX-01`, `MEASURE-01`, `CORE-01`, `PW-01`, and `SHIP-01` are ready and may run -subject to the shared-file writer rules. Playwright implementation still starts only after `PW-01` and -`PW-02` produce accepted evidence. Framework writers start only after the shared -runtime and adapter contract are stable. +`PW-01`, `SHIP-01`, `AD-REACT`, `AD-VUE`, `AD-SVELTE`, `AD-ANGULAR`, `BOX-01`, +and `WEB-01` are ready and may run subject to the shared-file writer rules. +Playwright implementation still starts only after `PW-01` and `PW-02` produce +accepted evidence. Framework writers may now start against the accepted shared +runtime and adapter contract. ## Graph audit diff --git a/docs/delivery/evidence/CORE-01.md b/docs/delivery/evidence/CORE-01.md new file mode 100644 index 0000000..5b1b111 --- /dev/null +++ b/docs/delivery/evidence/CORE-01.md @@ -0,0 +1,11 @@ +# CORE-01 evidence + +- Revision/base: `dc0d56f` +- Environment: Linux 7.2.3-arch1-3 x86_64; Node v26.7.0; npm 11.19.0 +- Files changed: `src/mount.ts`, `src/primitives.ts`, `tests/stet.test.ts`, `tests/browser/scroll.spec.ts`, `agent/catalog.mjs`, and generated `agent/annotation-plan.d.ts`, `agent/capabilities.json`, `agent/schemas/annotation-plan.schema.json`, `agent/schemas/capabilities.schema.json`, and `agent/validate.generated.mjs`. +- Acceptance criteria checked: all six primitives default visible and expose `show()`/`hide()`; explicit hiding remains separate from viewport/intersection culling and survives refresh, resketch, resize, and scroll; mount descriptions, arrow labels, and sticky text detach and restore only their owned ARIA IDs; foreign and later-added IDs survive; arrow/sticky expose complete handles; destroy and post-destroy calls are harmless. +- Commands and exit status: worker `npm run agent:generate` — exit 0; final `npx vitest run tests/stet.test.ts` — exit 0, 38 passed; final `npm run check` — exit 0; final `npm run test:browser -- tests/browser/scroll.spec.ts --project=chromium` — exit 0, 6 passed; final `git diff --check` — exit 0. +- Browser/manual artifacts: targeted Chromium scroll/visibility behavior passed; no snapshot or generated visual artifact changed. +- Pre-existing failures: none encountered. +- Unverified items and reason: no additional browser engines or full browser suite were required by the task's targeted verification. +- Reviewer decision: APPROVE after four in-scope coverage gaps were fixed: hide while culled then un-cull, foreign/later ARIA for arrow and sticky, distinct settled `show()` promises, and fixture cleanup. Follow-up review found no remaining in-scope defects. diff --git a/docs/delivery/evidence/CORE-02.md b/docs/delivery/evidence/CORE-02.md new file mode 100644 index 0000000..9d6b709 --- /dev/null +++ b/docs/delivery/evidence/CORE-02.md @@ -0,0 +1,11 @@ +# CORE-02 evidence + +- Revision/base: `dc0d56f` +- Environment: Linux 7.2.3-arch1-3 x86_64; Node v26.7.0; npm 11.19.0 +- Files changed: `src/mount.ts`, `agent/catalog.mjs`, `scripts/agent/generate.mjs`, `tests/stet.test.ts`, `tests/agent/schema.test.ts`, and generated `agent/annotation-plan.d.ts`, `agent/capabilities.json`, `agent/schemas/annotation-plan.schema.json`, `agent/schemas/capabilities.schema.json`, and `agent/validate.generated.mjs`. +- Acceptance criteria checked: flat animation options are public; animation defaults off; supplied timing opts in unless `animate:false`; enabled values resolve to 600ms duration and 0ms delay; supplied timing must be finite and nonnegative; only circle and underline accept enabled reveal; every unsupported primitive fails before DOM/ARIA mutation; generated plan validation follows the same support and timing rules. +- Commands and exit status: worker and final `npm run agent:generate` — exit 0; final `npx vitest run tests/stet.test.ts tests/agent/schema.test.ts` — exit 0, 84 passed; final `npm run check` — exit 0; final `git diff --check` — exit 0. +- Browser/manual artifacts: not applicable; CORE-02 contains validation only and forbids animation rendering. +- Pre-existing failures: none encountered. +- Unverified items and reason: no browser check required because this task adds no rendering behavior. +- Reviewer decision: APPROVE after correcting advertised supported-primitive defaults to include `animate:false`; follow-up review found no remaining in-scope defects. diff --git a/docs/delivery/evidence/CORE-03.md b/docs/delivery/evidence/CORE-03.md new file mode 100644 index 0000000..1fe3978 --- /dev/null +++ b/docs/delivery/evidence/CORE-03.md @@ -0,0 +1,11 @@ +# CORE-03 evidence + +- Revision/base: `dc0d56f` +- Environment: Linux 7.2.3-arch1-3 x86_64; Node v26.7.0; npm 11.19.0 +- Files changed: `src/mount.ts`, `tests/stet.test.ts`, and new `tests/browser/reveal.spec.ts`. +- Acceptance criteria checked: opt-in underline entrance uses normalized SVG path-length dash offset from 1 to 0 without opacity; first paint is fully undrawn; duration and delay are clock-based; initial `visible:false` defers entrance until `show()`; refresh, resketch, scroll, and resize preserve progress/deadline; offscreen and detached operations settle while culled and reconnect at the final frame; static, zero-duration, and initial reduced-motion paths remain immediate. +- Commands and exit status: final `npx vitest run tests/stet.test.ts` — exit 0, 65 passed; final `npm run check` — exit 0; `npm run test:browser -- tests/browser/reveal.spec.ts --project=chromium` — exit 0, 7 passed; `npm run test:browser -- tests/browser/scroll.spec.ts --project=chromium` — exit 0, 6 passed; final `git diff --check` — exit 0. +- Browser/manual artifacts: `/tmp/opencode/CORE-03-mid.png` showed an opaque partial stroke at measured dash offset 0.4834; `/tmp/opencode/CORE-03-final.png` showed the complete same stroke with reveal attributes removed. Reviewer inspected both. No checked-in snapshots changed. +- Pre-existing failures: none encountered. +- Unverified items and reason: Firefox/WebKit were not required by the task's targeted browser verification. +- Reviewer decision: APPROVE. Follow-up confirmed logical completion while still culled and detached/reconnected final-frame behavior; no remaining in-scope defects. diff --git a/docs/delivery/evidence/CORE-04.md b/docs/delivery/evidence/CORE-04.md new file mode 100644 index 0000000..c2d9b0a --- /dev/null +++ b/docs/delivery/evidence/CORE-04.md @@ -0,0 +1,11 @@ +# CORE-04 evidence + +- Revision/base: `dc0d56f` +- Environment: Linux 7.2.3-arch1-3 x86_64; Node v26.7.0; npm 11.19.0 +- Files changed: `src/mount.ts`, `tests/stet.test.ts`, and `tests/browser/reveal.spec.ts`. +- Acceptance criteria checked: circle joins the existing underline path-length controller; circle and underline share timing, delay, hidden attach, culling/detachment, reduced-motion, zero-duration, and refresh/resketch behavior; circle never uses opacity reveal; settled animated geometry is byte-identical to static geometry for the same seed. +- Commands and exit status: final `npx vitest run tests/stet.test.ts` — exit 0, 72 passed; final `npm run check` — exit 0; final `npm run test:browser -- tests/browser/reveal.spec.ts --project=chromium` — exit 0, 12 passed; final `git diff --check` — exit 0. Generated outputs remained unchanged after build. +- Browser/manual artifacts: `/tmp/opencode/CORE-04-mid.png` showed an opaque partial oval at dash offset about 0.4997; `/tmp/opencode/CORE-04-final.png` showed the complete stable ellipse with reveal attributes removed. Reviewer inspected both. No snapshots changed. +- Pre-existing failures: none encountered. +- Unverified items and reason: Firefox/WebKit were not required by the task's targeted browser verification. +- Reviewer decision: APPROVE with no confirmed findings or missing required coverage. diff --git a/docs/delivery/evidence/CORE-05.md b/docs/delivery/evidence/CORE-05.md new file mode 100644 index 0000000..7bb85be --- /dev/null +++ b/docs/delivery/evidence/CORE-05.md @@ -0,0 +1,11 @@ +# CORE-05 evidence + +- Revision/base: `dc0d56f` +- Environment: Linux 7.2.3-arch1-3 x86_64; Node v26.7.0; npm 11.19.0 +- Files changed: `src/mount.ts`, `src/primitives.ts`, `src/index.ts`, `tests/stet.test.ts`, and `tests/browser/reveal.spec.ts`. +- Acceptance criteria checked: one operation per handle; `show()` joins the exact attach/replay promise; settled/static calls return fresh fulfilled results; replay cancels and supersedes delaying/revealing operations before the replacement settles; hide/destroy cancel exactly once without rejection or late finish; hidden replay restores ARIA; zero duration and reduced motion create no operation; post-destroy methods are harmless and show/replay return fresh cancelled results; refresh/resketch preserve promise identity/deadline; arrow/sticky retain replay. +- Commands and exit status: final `npx vitest run tests/stet.test.ts` — exit 0, 100 passed; final `npm run check` — exit 0; `npm run test:browser -- tests/browser/reveal.spec.ts --project=chromium` — exit 0, 20 passed; final `git diff --check` — exit 0. +- Browser/manual artifacts: Chromium covered in-flight joining, replay supersession order, hide cancellation, and destroy cancellation for circle and underline. No snapshots changed. +- Pre-existing failures: none encountered. +- Unverified items and reason: live browser preference changes and ambient-motion composition are assigned to CORE-06. +- Reviewer decision: APPROVE after adding delaying replay, refresh/resketch promise identity, and arrow/sticky replay-ARIA coverage. No reproducible race or remaining CORE-05 defect. diff --git a/docs/delivery/evidence/CORE-06.md b/docs/delivery/evidence/CORE-06.md new file mode 100644 index 0000000..3c67090 --- /dev/null +++ b/docs/delivery/evidence/CORE-06.md @@ -0,0 +1,11 @@ +# CORE-06 evidence + +- Revision/base: `dc0d56f` +- Environment: Linux 7.2.3-arch1-3 x86_64; Node v26.7.0; npm 11.19.0 +- Files changed: `src/mount.ts`, `tests/stet.test.ts`, and `tests/browser/reveal.spec.ts`. +- Acceptance criteria checked: live reduced motion during delay/reveal/offscreen settles the current promise `finished` at the final frame; reduced motion disables hover and renders one stable boil variant; hover, boil, refresh, resketch, fonts, theme, resize, and scroll preserve operation identity/deadline and current progress; boil variants share reveal state without frame-zero/final flashes; deterministic reduced-motion captures are byte-stable with no active document animations. +- Commands and exit status: final `npx vitest run tests/stet.test.ts` — exit 0, 112 passed; final `npm run check` — exit 0; final `npm run test:browser -- tests/browser/reveal.spec.ts tests/browser/scroll.spec.ts --project=chromium` — exit 0, 33 passed; final `git diff --check` — exit 0. +- Browser/manual artifacts: browser capture check produced two byte-identical reduced-motion overlay screenshots and asserted one path/no active animations. No checked-in snapshots changed. +- Pre-existing failures: none encountered. +- Unverified items and reason: Firefox/WebKit were not required by the focused task verification. +- Reviewer decision: APPROVE. A real frame-zero boil flash was fixed by priming dash state while retaining the original operation deadline; reviewer found no remaining defect or missing required check. diff --git a/docs/delivery/evidence/CORE-07.md b/docs/delivery/evidence/CORE-07.md new file mode 100644 index 0000000..196607b --- /dev/null +++ b/docs/delivery/evidence/CORE-07.md @@ -0,0 +1,11 @@ +# CORE-07 evidence + +- Revision/base: `dc0d56f` +- Environment: Linux 7.2.3-arch1-3 x86_64; Node v26.7.0; npm 11.19.0 +- Files changed: `src/mount.ts`, `src/primitives.ts`, and `tests/stet.test.ts`. +- Acceptance criteria checked: source-only `updateHandle` uses a module-private per-handle capability with no registry or public handle/root export; complete snapshots update all six primitives and restore omitted values to defaults; identical snapshots no-op; explicit visibility and current resketched seed are preserved; explicit seed changes redraw at current progress; active operation identity/deadline stays unchanged; animation changes apply to the next entrance; descriptions, arrow labels, and sticky text update safely while visible, explicitly hidden, or culled; hover listeners toggle; invalid timing/support/required fields fail before mutation. +- Commands and exit status: final `npx vitest run tests/stet.test.ts tests/agent/schema.test.ts` — exit 0, 192 passed; final `npm run check` — exit 0; final `git diff --check` — exit 0. +- Browser/manual artifacts: no browser rendering change was required by CORE-07; existing CORE-06 browser output remained the accepted motion baseline. +- Pre-existing failures: none encountered. +- Unverified items and reason: adapter lifecycle behavior is intentionally deferred to the AD tasks; CORE-07 forbids adapter edits. +- Reviewer decision: APPROVE. Follow-up verified hidden/culled semantic updates, per-field placement checks, private capability/public-surface boundaries, and 164 focused runtime tests with no remaining in-scope findings. diff --git a/docs/delivery/tasks/AD-ANGULAR.md b/docs/delivery/tasks/AD-ANGULAR.md index 916a2bf..4962b09 100644 --- a/docs/delivery/tasks/AD-ANGULAR.md +++ b/docs/delivery/tasks/AD-ANGULAR.md @@ -1,6 +1,6 @@ # AD-ANGULAR — Angular full control ## Outcome -Angular directives implement the adapter contract. **Parent:** B7. **State:** BLOCKED. +Angular directives implement the adapter contract. **Parent:** B7. **State:** READY. ## Prerequisites CONTRACT-02, CORE-06, CORE-07 accepted. ## Required reading diff --git a/docs/delivery/tasks/AD-REACT.md b/docs/delivery/tasks/AD-REACT.md index 4376ad2..7af80bf 100644 --- a/docs/delivery/tasks/AD-REACT.md +++ b/docs/delivery/tasks/AD-REACT.md @@ -1,6 +1,6 @@ # AD-REACT — React full control ## Outcome -React implements the adapter contract. **Parent:** B7. **State:** BLOCKED. +React implements the adapter contract. **Parent:** B7. **State:** READY. ## Prerequisites CONTRACT-02, CORE-06, CORE-07 accepted. ## Required reading diff --git a/docs/delivery/tasks/AD-SVELTE.md b/docs/delivery/tasks/AD-SVELTE.md index 3f69831..9a7e0d7 100644 --- a/docs/delivery/tasks/AD-SVELTE.md +++ b/docs/delivery/tasks/AD-SVELTE.md @@ -1,6 +1,6 @@ # AD-SVELTE — Svelte full control ## Outcome -Svelte actions implement the adapter contract. **Parent:** B7. **State:** BLOCKED. +Svelte actions implement the adapter contract. **Parent:** B7. **State:** READY. ## Prerequisites CONTRACT-02, CORE-06, CORE-07 accepted. ## Required reading diff --git a/docs/delivery/tasks/AD-VUE.md b/docs/delivery/tasks/AD-VUE.md index f243150..fe49d15 100644 --- a/docs/delivery/tasks/AD-VUE.md +++ b/docs/delivery/tasks/AD-VUE.md @@ -1,6 +1,6 @@ # AD-VUE — Vue full control ## Outcome -Vue directives implement the adapter contract. **Parent:** B7. **State:** BLOCKED. +Vue directives implement the adapter contract. **Parent:** B7. **State:** READY. ## Prerequisites CONTRACT-02, CORE-06, CORE-07 accepted. ## Required reading diff --git a/docs/delivery/tasks/BOX-01.md b/docs/delivery/tasks/BOX-01.md index 1f5fa30..811ae88 100644 --- a/docs/delivery/tasks/BOX-01.md +++ b/docs/delivery/tasks/BOX-01.md @@ -1,6 +1,6 @@ # BOX-01 — Add deterministic box geometry ## Outcome -Internal rough-box geometry reuses stroked reveal. **Parent:** C1. **State:** BLOCKED. +Internal rough-box geometry reuses stroked reveal. **Parent:** C1. **State:** READY. ## Prerequisites CORE-06 accepted, so no concurrent writer is changing `src/rough.ts`. ## Required reading diff --git a/docs/delivery/tasks/CORE-01.md b/docs/delivery/tasks/CORE-01.md index 410a00b..f824742 100644 --- a/docs/delivery/tasks/CORE-01.md +++ b/docs/delivery/tasks/CORE-01.md @@ -3,7 +3,7 @@ ## Outcome All six current primitives implement the approved explicit visibility contract. ## Parent -B1. **State: READY.** +B1. **State: ACCEPTED.** ## Prerequisites Accepted CONTRACT-01. ## Required reading diff --git a/docs/delivery/tasks/CORE-02.md b/docs/delivery/tasks/CORE-02.md index 4ddf8cb..9e12be6 100644 --- a/docs/delivery/tasks/CORE-02.md +++ b/docs/delivery/tasks/CORE-02.md @@ -3,7 +3,7 @@ ## Outcome Runtime and plans agree on flat options, inferred opt-in, defaults, and supported primitives. ## Parent -B2/F1. **State: BLOCKED.** +B2/F1. **State: ACCEPTED.** ## Prerequisites Accepted CORE-01. ## Required reading diff --git a/docs/delivery/tasks/CORE-03.md b/docs/delivery/tasks/CORE-03.md index 9d07712..a3f06dd 100644 --- a/docs/delivery/tasks/CORE-03.md +++ b/docs/delivery/tasks/CORE-03.md @@ -3,7 +3,7 @@ ## Outcome The first opt-in finite stroked entrance. ## Parent -B2. **State: BLOCKED.** +B2. **State: ACCEPTED.** ## Prerequisites Accepted CORE-02. ## Required reading diff --git a/docs/delivery/tasks/CORE-04.md b/docs/delivery/tasks/CORE-04.md index c0d1e34..dee3698 100644 --- a/docs/delivery/tasks/CORE-04.md +++ b/docs/delivery/tasks/CORE-04.md @@ -3,7 +3,7 @@ ## Outcome Circle reuses the accepted stroked reveal mechanism. ## Parent -B2. **State: BLOCKED.** +B2. **State: ACCEPTED.** ## Prerequisites Accepted CORE-03. ## Required reading diff --git a/docs/delivery/tasks/CORE-05.md b/docs/delivery/tasks/CORE-05.md index f46a519..1a0f459 100644 --- a/docs/delivery/tasks/CORE-05.md +++ b/docs/delivery/tasks/CORE-05.md @@ -4,7 +4,7 @@ The approved asynchronous state machine works for underline and circle and is reusable by later supported reveal primitives. ## Parent -B4. **State: BLOCKED.** +B4. **State: ACCEPTED.** ## Prerequisites Accepted CORE-04 (which transitively requires CONTRACT-01). ## Required reading diff --git a/docs/delivery/tasks/CORE-06.md b/docs/delivery/tasks/CORE-06.md index dc0d919..027bed0 100644 --- a/docs/delivery/tasks/CORE-06.md +++ b/docs/delivery/tasks/CORE-06.md @@ -3,7 +3,7 @@ ## Outcome Reveal, boil, hover resketch, reduced motion, and capture settle coherently. ## Parent -B5. **State: BLOCKED.** +B5. **State: ACCEPTED.** ## Prerequisites Accepted CORE-05. ## Required reading diff --git a/docs/delivery/tasks/CORE-07.md b/docs/delivery/tasks/CORE-07.md index 0229b2e..e74ac9c 100644 --- a/docs/delivery/tasks/CORE-07.md +++ b/docs/delivery/tasks/CORE-07.md @@ -3,7 +3,7 @@ ## Outcome Adapters can update supported data without recreating entrance state. ## Parent -Required B6 subset. **State: BLOCKED.** +Required B6 subset. **State: ACCEPTED.** ## Prerequisites Accepted CONTRACT-02 and CORE-06. ## Required reading diff --git a/docs/delivery/tasks/WEB-01.md b/docs/delivery/tasks/WEB-01.md index 3a5a0f7..8ec2ed1 100644 --- a/docs/delivery/tasks/WEB-01.md +++ b/docs/delivery/tasks/WEB-01.md @@ -1,6 +1,6 @@ # WEB-01 — Make the homepage outcome-led ## Outcome -The first proof is a state-dependent live explanation. **Parent:** G1. **State:** BLOCKED. +The first proof is a state-dependent live explanation. **Parent:** G1. **State:** READY. ## Prerequisites FIX-01 and CORE-06 accepted. Use the accepted animated circle/underline example; do not wait for optional examples. diff --git a/scripts/agent/generate.mjs b/scripts/agent/generate.mjs index 470aa0a..558697f 100644 --- a/scripts/agent/generate.mjs +++ b/scripts/agent/generate.mjs @@ -1,146 +1,360 @@ -import ts from 'typescript'; -import Ajv from 'ajv'; -import standaloneCode from 'ajv/dist/standalone/index.js'; -import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { frameworks, primitives, defaults, strategies, tools, constraints } from '../../agent/catalog.mjs'; -import { lifecyclePattern } from '../../agent/patterns.mjs'; -import { snippet } from '../../agent/snippets.mjs'; +import ts from "typescript"; +import Ajv from "ajv"; +import standaloneCode from "ajv/dist/standalone/index.js"; +import { readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + frameworks, + primitives, + defaults, + strategies, + tools, + constraints, +} from "../../agent/catalog.mjs"; +import { lifecyclePattern } from "../../agent/patterns.mjs"; +import { snippet } from "../../agent/snippets.mjs"; -const root = fileURLToPath(new URL('../../', import.meta.url)); -const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'))); -const program = ts.createProgram(['src/index.ts', 'src/mount.ts', 'src/primitives.ts', 'src/react.ts', 'src/vue.ts', 'src/svelte.ts', 'src/angular.ts'].map(p => resolve(root, p)), { strict: true, target: ts.ScriptTarget.ES2020 }); +const root = fileURLToPath(new URL("../../", import.meta.url)); +const pkg = JSON.parse(readFileSync(resolve(root, "package.json"))); +const program = ts.createProgram( + [ + "src/index.ts", + "src/mount.ts", + "src/primitives.ts", + "src/react.ts", + "src/vue.ts", + "src/svelte.ts", + "src/angular.ts", + ].map((p) => resolve(root, p)), + { strict: true, target: ts.ScriptTarget.ES2020 }, +); const checker = program.getTypeChecker(); const declarations = new Map(); for (const file of program.getSourceFiles()) { - if (!file.fileName.startsWith(resolve(root, 'src'))) continue; - ts.forEachChild(file, node => { - if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) declarations.set(node.name.text, node); + if (!file.fileName.startsWith(resolve(root, "src"))) continue; + ts.forEachChild(file, (node) => { + if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) + declarations.set(node.name.text, node); }); } function schemaType(type) { if (type.isUnion()) { - const members = type.types.filter(t => !(t.flags & ts.TypeFlags.Undefined)); - if (members.every(t => t.isStringLiteral())) return { type: 'string', enum: members.map(t => t.value) }; - if (members.every(t => t.flags & ts.TypeFlags.BooleanLiteral)) return { type: 'boolean' }; + const members = type.types.filter((t) => !(t.flags & ts.TypeFlags.Undefined)); + if (members.every((t) => t.isStringLiteral())) + return { type: "string", enum: members.map((t) => t.value) }; + if (members.every((t) => t.flags & ts.TypeFlags.BooleanLiteral)) return { type: "boolean" }; if (members.length === 1) return schemaType(members[0]); throw new Error(`Unsupported union: ${checker.typeToString(type)}`); } - if (type.flags & ts.TypeFlags.String) return { type: 'string' }; - if (type.flags & ts.TypeFlags.Number) return { type: 'number' }; - if (type.flags & ts.TypeFlags.Boolean) return { type: 'boolean' }; + if (type.flags & ts.TypeFlags.String) return { type: "string" }; + if (type.flags & ts.TypeFlags.Number) return { type: "number" }; + if (type.flags & ts.TypeFlags.Boolean) return { type: "boolean" }; throw new Error(`Unsupported option type: ${checker.typeToString(type)}`); } -const object = (properties, required = Object.keys(properties)) => ({ type: 'object', additionalProperties: false, properties, required }); -const nonblank = { type: 'string', pattern: '\\S' }; +const object = (properties, required = Object.keys(properties)) => ({ + type: "object", + additionalProperties: false, + properties, + required, +}); +const nonblank = { type: "string", pattern: "\\S" }; const optionSchemas = {}; -for (const name of ['StetOptions', 'ArrowOptions', 'StickyOptions']) { +for (const name of ["StetOptions", "ArrowOptions", "StickyOptions"]) { const type = checker.getTypeAtLocation(declarations.get(name)); - const properties = {}, required = []; + const properties = {}, + required = []; for (const prop of type.getProperties()) { - properties[prop.name] = schemaType(checker.getTypeOfSymbolAtLocation(prop, prop.valueDeclaration)); - if (['text', 'label', 'description'].includes(prop.name)) properties[prop.name].pattern = '\\S'; + properties[prop.name] = schemaType( + checker.getTypeOfSymbolAtLocation(prop, prop.valueDeclaration), + ); + if (["animationDuration", "animationDelay"].includes(prop.name)) + properties[prop.name].minimum = 0; + if (["text", "label", "description"].includes(prop.name)) properties[prop.name].pattern = "\\S"; if (!(prop.flags & ts.SymbolFlags.Optional)) required.push(prop.name); } optionSchemas[name] = object(properties, required); } -const markKind = schemaType(checker.getTypeAtLocation(declarations.get('MarkKind'))); -const target = object({ strategy: { enum: strategies, type: 'string' }, file: nonblank, locator: nonblank, description: nonblank, rationale: nonblank }, ['strategy', 'file', 'locator', 'description']); -target.allOf = [{ if: { properties: { strategy: { const: 'css' } }, required: ['strategy'] }, then: { properties: { rationale: nonblank }, required: ['rationale'] } }]; -const annotations = Object.entries(primitives).map(([name, meta]) => object({ - id: { type: 'string', pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$' }, - primitive: { const: name, type: 'string' }, - targets: { type: 'array', items: target, minItems: meta.targets.length, maxItems: meta.targets.length }, - options: optionSchemas[meta.type], - ...(name === 'mark' ? { kind: markKind } : {}), -}, ['id', 'primitive', 'targets', ...(name === 'sticky' ? ['options'] : []), ...(name === 'mark' ? ['kind'] : [])])); -const plan = { $schema: 'http://json-schema.org/draft-07/schema#', title: 'Stet Annotation Plan v1', ...object({ - version: { type: 'integer', const: 1 }, - framework: { type: 'string', enum: Object.keys(frameworks) }, - intent: nonblank, - annotations: { type: 'array', minItems: 1, items: { oneOf: annotations } }, -}) }; -const integrations = Object.fromEntries(Object.entries(frameworks).map(([name, meta]) => { - const source = program.getSourceFile(resolve(root, `src/${name === 'vanilla' ? 'index' : name}.ts`)); - const exported = checker.getExportsOfModule(checker.getSymbolAtLocation(source)).filter(s => { - const symbol = s.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s; - return symbol.flags & ts.SymbolFlags.Value; - }).map(s => s.name); - const symbols = Object.fromEntries(Object.keys(primitives).map(primitive => { - const title = primitive[0].toUpperCase() + primitive.slice(1); - const symbol = name === 'react' ? title : name === 'vue' ? `vStet${title}` : name === 'angular' ? `Stet${title}Directive` : primitive; - if (!exported.includes(symbol)) throw new Error(`Missing source export: ${name}/${symbol}`); - return [primitive, symbol]; - })); - if (exported.length !== Object.keys(primitives).length) throw new Error(`Uncatalogued runtime exports in ${name}: ${exported}`); - return [name, { ...meta, import: pkg.name + meta.subpath, symbols }]; -})); +// CORE-02: reveal is supported only by circle/underline. Unsupported primitives +// reject enabled animation so plan validation agrees with runtime attachment. +const revealSupported = new Set(["circle", "underline"]); +const animationOptions = ["animate", "animationDuration", "animationDelay"]; +const withoutAnimation = (schema) => ({ + ...schema, + allOf: [ + { properties: { animate: { not: { const: true } } } }, + { + if: { + anyOf: [ + { required: ["animationDuration"], properties: { animationDuration: true } }, + { required: ["animationDelay"], properties: { animationDelay: true } }, + ], + }, + then: { properties: { animate: { const: false } }, required: ["animate"] }, + }, + ], +}); +const markKind = schemaType(checker.getTypeAtLocation(declarations.get("MarkKind"))); +const target = object( + { + strategy: { enum: strategies, type: "string" }, + file: nonblank, + locator: nonblank, + description: nonblank, + rationale: nonblank, + }, + ["strategy", "file", "locator", "description"], +); +target.allOf = [ + { + if: { properties: { strategy: { const: "css" } }, required: ["strategy"] }, + then: { properties: { rationale: nonblank }, required: ["rationale"] }, + }, +]; +const annotations = Object.entries(primitives).map(([name, meta]) => + object( + { + id: { type: "string", pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" }, + primitive: { const: name, type: "string" }, + targets: { + type: "array", + items: target, + minItems: meta.targets.length, + maxItems: meta.targets.length, + }, + options: revealSupported.has(name) + ? optionSchemas[meta.type] + : withoutAnimation(optionSchemas[meta.type]), + ...(name === "mark" ? { kind: markKind } : {}), + }, + [ + "id", + "primitive", + "targets", + ...(name === "sticky" ? ["options"] : []), + ...(name === "mark" ? ["kind"] : []), + ], + ), +); +const plan = { + $schema: "http://json-schema.org/draft-07/schema#", + title: "Stet Annotation Plan v1", + ...object({ + version: { type: "integer", const: 1 }, + framework: { type: "string", enum: Object.keys(frameworks) }, + intent: nonblank, + annotations: { type: "array", minItems: 1, items: { oneOf: annotations } }, + }), +}; +const integrations = Object.fromEntries( + Object.entries(frameworks).map(([name, meta]) => { + const source = program.getSourceFile( + resolve(root, `src/${name === "vanilla" ? "index" : name}.ts`), + ); + const exported = checker + .getExportsOfModule(checker.getSymbolAtLocation(source)) + .filter((s) => { + const symbol = s.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s; + return symbol.flags & ts.SymbolFlags.Value; + }) + .map((s) => s.name); + const symbols = Object.fromEntries( + Object.keys(primitives).map((primitive) => { + const title = primitive[0].toUpperCase() + primitive.slice(1); + const symbol = + name === "react" + ? title + : name === "vue" + ? `vStet${title}` + : name === "angular" + ? `Stet${title}Directive` + : primitive; + if (!exported.includes(symbol)) throw new Error(`Missing source export: ${name}/${symbol}`); + return [primitive, symbol]; + }), + ); + if (exported.length !== Object.keys(primitives).length) + throw new Error(`Uncatalogued runtime exports in ${name}: ${exported}`); + return [name, { ...meta, import: pkg.name + meta.subpath, symbols }]; + }), +); for (const [name, meta] of Object.entries(primitives)) { - const source = program.getSourceFile(resolve(root, 'src/primitives.ts')); - const fn = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === name); - const targets = fn.parameters.filter(p => checker.typeToString(checker.getTypeAtLocation(p)) === 'Element'); + const source = program.getSourceFile(resolve(root, "src/primitives.ts")); + const fn = source.statements.find( + (node) => ts.isFunctionDeclaration(node) && node.name?.text === name, + ); + const targets = fn.parameters.filter( + (p) => checker.typeToString(checker.getTypeAtLocation(p)) === "Element", + ); if (targets.length !== meta.targets.length) throw new Error(`Target arity drift: ${name}`); } const capabilities = { - contractVersion: 1, package: { name: pkg.name, version: pkg.version, exports: Object.keys(pkg.exports) }, + contractVersion: 1, + package: { name: pkg.name, version: pkg.version, exports: Object.keys(pkg.exports) }, frameworks: integrations, - primitives: Object.fromEntries(Object.entries(primitives).map(([name, meta]) => [name, { - ...meta, targetType: 'Element', options: optionSchemas[meta.type], defaults: Object.fromEntries(Object.keys(optionSchemas[meta.type].properties).filter(k => k in defaults || k === 'padding' && meta.padding !== undefined).map(k => [k, k === 'padding' ? meta.padding : defaults[k]])), - ...(name === 'mark' ? { kind: markKind } : {}), - effects: name === 'highlight' ? 'fill controls wash; stroke/width have no visible effect; padding positions the overlay' : name === 'arrow' ? 'fill/padding have no visible effect; curvature is clamped to -0.8…0.8' : name === 'sticky' ? 'fill controls paper; stroke controls text; width has no visible effect' : 'fill has no visible effect', - }])), - targetPreference: strategies, constraints, tools, - cli: { node: '>=20', commands: ['inspect [--project ]', 'schema annotation-plan|capabilities', 'validate ', 'snippet --framework ', 'snippet --pattern lifecycle --framework ', 'agent init|update --tool ', '--help', '--version'], exitCodes: { success: 0, invalidPlan: 1, usage: 2, io: 3, conflict: 4 } }, + primitives: Object.fromEntries( + Object.entries(primitives).map(([name, meta]) => [ + name, + { + ...meta, + targetType: "Element", + options: optionSchemas[meta.type], + defaults: Object.fromEntries( + Object.keys(optionSchemas[meta.type].properties) + .filter( + (k) => + (k in defaults || (k === "padding" && meta.padding !== undefined)) && + (revealSupported.has(name) || !animationOptions.includes(k)), + ) + .map((k) => [k, k === "padding" ? meta.padding : defaults[k]]), + ), + ...(name === "mark" ? { kind: markKind } : {}), + effects: + name === "highlight" + ? "fill controls wash; stroke/width have no visible effect; padding positions the overlay" + : name === "arrow" + ? "fill/padding have no visible effect; curvature is clamped to -0.8…0.8" + : name === "sticky" + ? "fill controls paper; stroke controls text; width has no visible effect" + : "fill has no visible effect", + }, + ]), + ), + targetPreference: strategies, + constraints, + tools, + cli: { + node: ">=20", + commands: [ + "inspect [--project ]", + "schema annotation-plan|capabilities", + "validate ", + "snippet --framework ", + "snippet --pattern lifecycle --framework ", + "agent init|update --tool ", + "--help", + "--version", + ], + exitCodes: { success: 0, invalidPlan: 1, usage: 2, io: 3, conflict: 4 }, + }, }; // A version-specific exact schema makes stale or hand-edited capability snapshots detectable. -const capSchema = { $schema: plan.$schema, title: `Stet ${pkg.version} capabilities`, type: 'object', const: capabilities }; +const capSchema = { + $schema: plan.$schema, + title: `Stet ${pkg.version} capabilities`, + type: "object", + const: capabilities, +}; function typeOf(schema) { if (schema.const !== undefined) return JSON.stringify(schema.const); - if (schema.enum) return schema.enum.map(x => JSON.stringify(x)).join(' | '); - if (schema.oneOf) return schema.oneOf.map(typeOf).join(' | '); - if (schema.type === 'object') return '{\n' + Object.entries(schema.properties).map(([k,v]) => `${JSON.stringify(k)}${schema.required.includes(k) ? '' : '?'}: ${typeOf(v)};`).join('\n') + '\n}'; - if (schema.type === 'array') return schema.minItems === schema.maxItems && schema.maxItems ? `[${Array(schema.minItems).fill(typeOf(schema.items)).join(', ')}]` : `Array<${typeOf(schema.items)}>`; - return schema.type === 'integer' ? 'number' : schema.type; + if (schema.enum) return schema.enum.map((x) => JSON.stringify(x)).join(" | "); + if (schema.oneOf) return schema.oneOf.map(typeOf).join(" | "); + if (schema.type === "object") + return ( + "{\n" + + Object.entries(schema.properties) + .map( + ([k, v]) => + `${JSON.stringify(k)}${schema.required.includes(k) ? "" : "?"}: ${typeOf(v)};`, + ) + .join("\n") + + "\n}" + ); + if (schema.type === "array") + return schema.minItems === schema.maxItems && schema.maxItems + ? `[${Array(schema.minItems).fill(typeOf(schema.items)).join(", ")}]` + : `Array<${typeOf(schema.items)}>`; + return schema.type === "integer" ? "number" : schema.type; } const ajv = new Ajv({ allErrors: true, code: { source: true, esm: true }, strict: true }); const validate = ajv.compile(plan); const outputs = { - 'agent/schemas/annotation-plan.schema.json': JSON.stringify(plan, null, 2) + '\n', - 'agent/schemas/capabilities.schema.json': JSON.stringify(capSchema, null, 2) + '\n', - 'agent/capabilities.json': JSON.stringify(capabilities, null, 2) + '\n', - 'agent/annotation-plan.d.ts': '// Generated by scripts/agent/generate.mjs. Do not edit.\nexport type AnnotationPlan = ' + typeOf(plan) + ';\n', - 'agent/LICENSE-ajv.txt': readFileSync(resolve(root, 'node_modules/ajv/LICENSE'), 'utf8'), - 'agent/validate.generated.mjs': '// Generated by Ajv (MIT); see LICENSE-ajv.txt. Do not edit.\n' + standaloneCode(ajv, validate) + '\n', + "agent/schemas/annotation-plan.schema.json": JSON.stringify(plan, null, 2) + "\n", + "agent/schemas/capabilities.schema.json": JSON.stringify(capSchema, null, 2) + "\n", + "agent/capabilities.json": JSON.stringify(capabilities, null, 2) + "\n", + "agent/annotation-plan.d.ts": + "// Generated by scripts/agent/generate.mjs. Do not edit.\nexport type AnnotationPlan = " + + typeOf(plan) + + ";\n", + "agent/LICENSE-ajv.txt": readFileSync(resolve(root, "node_modules/ajv/LICENSE"), "utf8"), + "agent/validate.generated.mjs": + "// Generated by Ajv (MIT); see LICENSE-ajv.txt. Do not edit.\n" + + standaloneCode(ajv, validate) + + "\n", }; -for (const framework of Object.keys(frameworks)) for (const primitive of Object.keys(primitives)) { - const result = snippet(primitive, framework); - if (result.extension !== frameworks[framework].extension) throw new Error(`Template extension drift: ${framework}`); - outputs[`agent/templates/${framework}/${primitive}.${result.extension}`] = result.code; -} +for (const framework of Object.keys(frameworks)) + for (const primitive of Object.keys(primitives)) { + const result = snippet(primitive, framework); + if (result.extension !== frameworks[framework].extension) + throw new Error(`Template extension drift: ${framework}`); + outputs[`agent/templates/${framework}/${primitive}.${result.extension}`] = result.code; + } for (const framework of Object.keys(frameworks)) { const result = lifecyclePattern(framework); outputs[`agent/templates/${framework}/lifecycle.${result.extension}`] = result.code; } -const exampleTarget = (locator, description) => ({ strategy: 'id', file: 'src/settings.html', locator, description }); -const example = { version: 1, framework: 'vanilla', intent: 'Illustrative settings-screen plan: adapt file names, IDs and consequences to inspected application source before use.', annotations: [ - { id: 'danger', primitive: 'mark', kind: 'wrong', targets: [exampleTarget('delete-settings', 'Existing delete settings control')], options: { seed: 42, description: 'Deletes saved settings. Verify this consequence against the application handler.' } }, - { id: 'consequence', primitive: 'arrow', targets: [exampleTarget('delete-settings', 'Existing delete settings control'), exampleTarget('delete-warning', 'Existing native consequence explanation')], options: { seed: 43, label: 'Read the consequences here.' } }, -] }; -if (!validate(example)) throw new Error('Generated example violates the plan schema'); -outputs['agent/examples/settings.plan.json'] = JSON.stringify(example, null, 2) + '\n'; -const routes = JSON.parse(readFileSync(resolve(root, 'agent/evals/routing.json'), 'utf8')); -for (const skill of readdirSync(resolve(root, 'agent/skills')).sort()) { - outputs[`agent/skills/${skill}/evals/trigger_evals.json`] = JSON.stringify(routes.map(route => ({ query: route.query, should_trigger: route.expected === skill, note: `Entry skill: ${route.expected}` })), null, 2) + '\n'; +const exampleTarget = (locator, description) => ({ + strategy: "id", + file: "src/settings.html", + locator, + description, +}); +const example = { + version: 1, + framework: "vanilla", + intent: + "Illustrative settings-screen plan: adapt file names, IDs and consequences to inspected application source before use.", + annotations: [ + { + id: "danger", + primitive: "mark", + kind: "wrong", + targets: [exampleTarget("delete-settings", "Existing delete settings control")], + options: { + seed: 42, + description: + "Deletes saved settings. Verify this consequence against the application handler.", + }, + }, + { + id: "consequence", + primitive: "arrow", + targets: [ + exampleTarget("delete-settings", "Existing delete settings control"), + exampleTarget("delete-warning", "Existing native consequence explanation"), + ], + options: { seed: 43, label: "Read the consequences here." }, + }, + ], +}; +if (!validate(example)) throw new Error("Generated example violates the plan schema"); +outputs["agent/examples/settings.plan.json"] = JSON.stringify(example, null, 2) + "\n"; +const routes = JSON.parse(readFileSync(resolve(root, "agent/evals/routing.json"), "utf8")); +for (const skill of readdirSync(resolve(root, "agent/skills")).sort()) { + outputs[`agent/skills/${skill}/evals/trigger_evals.json`] = + JSON.stringify( + routes.map((route) => ({ + query: route.query, + should_trigger: route.expected === skill, + note: `Entry skill: ${route.expected}`, + })), + null, + 2, + ) + "\n"; } let stale = false; for (const [path, content] of Object.entries(outputs)) { - if (process.argv.includes('--check')) { + if (process.argv.includes("--check")) { let existing; - try { existing = readFileSync(resolve(root, path), 'utf8'); } catch {} - if (existing !== content) { console.error(`Stale generated artifact: ${path}`); stale = true; } + try { + existing = readFileSync(resolve(root, path), "utf8"); + } catch {} + if (existing !== content) { + console.error(`Stale generated artifact: ${path}`); + stale = true; + } } else { - mkdirSync(resolve(root, path, '..'), { recursive: true }); + mkdirSync(resolve(root, path, ".."), { recursive: true }); writeFileSync(resolve(root, path), content); } } diff --git a/src/index.ts b/src/index.ts index a20e3d7..c87eef4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export { arrow, circle, highlight, mark, sticky, underline } from "./primitives. export type { ArrowOptions, MarkKind, + StetAnimationResult, StetHandle, StetOptions, StickyOptions, diff --git a/src/mount.ts b/src/mount.ts index 4b2916f..dbadf07 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -10,23 +10,138 @@ export interface StetOptions { width?: number; resketchOnHover?: boolean; padding?: number; + /** Whether the annotation starts visible. Defaults to true. */ + visible?: boolean; + /** Opt into reveal animation. Defaults to off unless duration or delay is supplied. */ + animate?: boolean; + /** Reveal duration in milliseconds. Defaults to 600 when animation is enabled. */ + animationDuration?: number; + /** Delay before reveal in milliseconds. Defaults to 0 when animation is enabled. */ + animationDelay?: number; /** Accessible meaning for an otherwise decorative mark. */ description?: string; } +export type StetAnimationResult = { status: "finished" | "cancelled" }; + export interface StetHandle { + /** Clear explicit hiding and re-expose owned ARIA relationships. */ + show(): Promise; + /** Explicitly hide and detach owned ARIA relationships until show(). */ + hide(): void; + /** Restore visibility at the current seed and start a fresh entrance. */ + replay(): Promise; resketch(seed?: number): void; /** Follow a layout change without changing the drawing's seed. */ refresh(): void; destroy(): void; } -export interface Renderer { - (root: HTMLDivElement, svg: SVGSVGElement, options: RoughOptions): void; +/** A Stet-owned aria-describedby relationship that survives hide/show. */ +export interface AriaLink { + attach(): void; + detach(): void; + remove(): void; +} + +export interface MountedAnnotation { + handle: StetHandle; + /** Register an owned ARIA relationship with this annotation's lifecycle. */ + own(link: AriaLink): void; +} + +export interface RenderContext { + rough: RoughOptions; + /** Live snapshotted runtime options; replaced wholesale by updateHandle. */ + options: O; + /** Register an owned ARIA relationship with this annotation's lifecycle. */ + own(link: AriaLink): void; + /** Drop a previously owned relationship and remove its ID immediately. */ + disown(link: AriaLink): void; +} + +export interface Renderer { + (root: HTMLDivElement, svg: SVGSVGElement, context: RenderContext): void; +} + +/** + * Complete flat runtime snapshot accepted by {@link updateHandle}. It excludes + * `visible`, `onHandle`, and adapter targets; primitive-specific keys travel + * through to the renderer untouched. + */ +export type StetRuntimeOptions = StetOptions | Record; + +const updateCapability: unique symbol = Symbol("stet.update"); + +interface UpdateCapability { + update(options: StetRuntimeOptions): void; +} + +function sameRuntimeOptions(a: StetOptions, b: StetOptions): boolean { + const left = a as Record; + const right = b as Record; + const keys = new Set([...Object.keys(left), ...Object.keys(right)]); + keys.delete("visible"); + keys.delete("onHandle"); + for (const key of keys) { + if (left[key] !== right[key]) return false; + } + return true; +} + +/** + * Source-only nonreplaying update path. Adapters import this directly from + * `src/mount.ts`; it is intentionally not re-exported by the package entry and + * is absent from {@link StetHandle}. `visible` and adapter metadata are ignored. + */ +export function updateHandle(handle: StetHandle, nextOptions: StetRuntimeOptions): void { + const capability = (handle as unknown as { [updateCapability]?: UpdateCapability })[ + updateCapability + ]; + if (!capability) throw new TypeError("stet: updateHandle requires a handle created by stet"); + capability.update(nextOptions); } const SVG_NS = "http://www.w3.org/2000/svg"; +// CORE-02 reveal support; BOX-02 adds "box" when that primitive becomes public. +const revealSupported = new Set(["circle", "underline"]); + +// CORE-03/CORE-04 path-length draw-on shared by every stroked primitive; the +// mechanism stays one treatment rather than a per-primitive controller. +const strokedRevealPrimitives = new Set(["circle", "underline"]); + +export interface ResolvedAnimation { + enabled: boolean; + duration: number; + delay: number; +} + +/** Resolve flat animation options. Timing validation lives in assertRevealSupport. */ +export function resolveAnimation(options: StetOptions): ResolvedAnimation { + const optedIn = options.animationDuration !== undefined || options.animationDelay !== undefined; + return { + enabled: options.animate === true || (options.animate !== false && optedIn), + duration: options.animationDuration ?? 600, + delay: options.animationDelay ?? 0, + }; +} + +/** Validate timing and reveal support before any DOM or ARIA mutation. */ +export function assertRevealSupport(primitive: string, options: StetOptions): void { + for (const [name, value] of [ + ["animationDuration", options.animationDuration], + ["animationDelay", options.animationDelay], + ] as const) { + if (value !== undefined && (!Number.isFinite(value) || value < 0)) { + throw new RangeError(`stet: ${name} must be a finite, nonnegative number`); + } + } + if (resolveAnimation(options).enabled && !revealSupported.has(primitive)) { + throw new TypeError(`stet: animation is not supported by ${primitive}`); + } +} + // A single passive listener coalesces tracking for overlays that cannot scroll // natively with the document (nested scrollers and viewport-aware notes). const scrollUpdates = new Set<() => void>(); @@ -40,7 +155,8 @@ function onScroll(): void { } function trackScroll(update: () => void): () => void { - if (!scrollUpdates.size) window.addEventListener("scroll", onScroll, { capture: true, passive: true }); + if (!scrollUpdates.size) + window.addEventListener("scroll", onScroll, { capture: true, passive: true }); scrollUpdates.add(update); return () => { scrollUpdates.delete(update); @@ -56,8 +172,13 @@ function scrollsWithDocument(target: Element): boolean { for (let node: Element | null = target; node; node = node.parentElement) { const style = getComputedStyle(node); if (style.position === "fixed" || style.position === "sticky") return false; - if (node !== target && node !== document.body && node !== document.documentElement && - /auto|scroll|hidden|overlay/.test(`${style.overflowX} ${style.overflowY}`)) return false; + if ( + node !== target && + node !== document.body && + node !== document.documentElement && + /auto|scroll|hidden|overlay/.test(`${style.overflowX} ${style.overflowY}`) + ) + return false; } return true; } @@ -115,7 +236,11 @@ export function place( export function clientBoxes(element: Element, text = false): DOMRect[] { let source = element.getClientRects(); - if (text && element.matches("p,h1,h2,h3,h4,h5,h6,blockquote,li,span,a,strong,em,b,i,small,code,label") && element.textContent?.trim()) { + if ( + text && + element.matches("p,h1,h2,h3,h4,h5,h6,blockquote,li,span,a,strong,em,b,i,small,code,label") && + element.textContent?.trim() + ) { const range = document.createRange(); range.selectNodeContents(element); source = range.getClientRects(); @@ -124,8 +249,10 @@ export function clientBoxes(element: Element, text = false): DOMRect[] { for (const rect of Array.from(source).filter((r) => r.width && r.height)) { const line = rects.findIndex((r) => { const overlap = Math.min(r.bottom, rect.bottom) - Math.max(r.top, rect.top); - return overlap > Math.min(r.height, rect.height) * 0.6 && - Math.max(r.height, rect.height) < Math.min(r.height, rect.height) * 1.8; + return ( + overlap > Math.min(r.height, rect.height) * 0.6 && + Math.max(r.height, rect.height) < Math.min(r.height, rect.height) * 1.8 + ); }); if (line < 0) rects.push(rect); else rects[line] = bounds([rects[line], rect]); @@ -144,11 +271,24 @@ export function bounds(rects: DOMRect[]): DOMRect { function applyTheme(root: HTMLElement, options: StetOptions): void { if (options.stroke) root.style.setProperty("--stet-ink", options.stroke); + else root.style.removeProperty("--stet-ink"); if (options.fill) root.style.setProperty("--stet-local-fill", options.fill); + else root.style.removeProperty("--stet-local-fill"); if (options.width !== undefined) root.style.setProperty("--stet-width", String(options.width)); } -const themeTokens = ["stroke", "fill", "paper", "width", "correct", "wrong", "note", "font", "z-index", "highlight-blend"]; +const themeTokens = [ + "stroke", + "fill", + "paper", + "width", + "correct", + "wrong", + "note", + "font", + "z-index", + "highlight-blend", +]; function inheritTheme(root: HTMLElement, target: Element, options: StetOptions): void { const style = getComputedStyle(target); @@ -156,7 +296,8 @@ function inheritTheme(root: HTMLElement, target: Element, options: StetOptions): for (const token of themeTokens) { const property = `--stet-${token}`; const value = style.getPropertyValue(property); - if (value && value !== inherited.getPropertyValue(property)) root.style.setProperty(property, value); + if (value && value !== inherited.getPropertyValue(property)) + root.style.setProperty(property, value); else root.style.removeProperty(property); } applyTheme(root, options); @@ -164,31 +305,54 @@ function inheritTheme(root: HTMLElement, target: Element, options: StetOptions): let descriptionId = 0; -export function describe(element: Element, text: HTMLElement): () => void { +export function describe(element: Element, text: HTMLElement): AriaLink { let id: string; - do { id = `stet-description-${++descriptionId}`; } while (document.getElementById(id)); + do { + id = `stet-description-${++descriptionId}`; + } while (document.getElementById(id)); text.id = id; - const ids = element.getAttribute("aria-describedby")?.split(/\s+/).filter(Boolean) ?? []; - element.setAttribute("aria-describedby", [...ids, text.id].join(" ")); - return () => { - const remaining = (element.getAttribute("aria-describedby")?.split(/\s+/) ?? []).filter((id) => id && id !== text.id); - if (remaining.length) element.setAttribute("aria-describedby", remaining.join(" ")); + const read = () => element.getAttribute("aria-describedby")?.split(/\s+/).filter(Boolean) ?? []; + const write = (ids: string[]) => { + if (ids.length) element.setAttribute("aria-describedby", ids.join(" ")); else element.removeAttribute("aria-describedby"); }; + const link: AriaLink = { + attach() { + const ids = read(); + // Append the owned ID once, after any foreign IDs the application owns. + if (!ids.includes(id)) write([...ids, id]); + }, + detach() { + write(read().filter((value) => value !== id)); + }, + remove() { + link.detach(); + }, + }; + link.attach(); + return link; } -export function mount( +export function mount( targets: Element[], primitive: string, - options: StetOptions, - render: Renderer, + options: O, + render: Renderer, viewportAware = false, -): StetHandle { + validate?: (options: O) => void, +): MountedAnnotation { + assertRevealSupport(primitive, options); targets.forEach((target, index) => assertElement(target, index ? `element ${index + 1}` : "element"), ); if (!document.body) throw new Error("stet: document.body is required"); + // The live snapshot: updateHandle replaces it wholesale so removed values fall + // back to defaults instead of lingering from a previous render. + let runtime: O = { ...options }; + let lastSeedOption = options.seed; + let seed = options.seed ?? randomSeed(); + const root = document.createElement("div"); root.className = `stet-overlay stet-overlay--${primitive}`; const svg = document.createElementNS(SVG_NS, "svg"); @@ -196,61 +360,234 @@ export function mount( svg.setAttribute("aria-hidden", "true"); root.append(svg); document.body.append(root); - inheritTheme(root, targets[0], options); + inheritTheme(root, targets[0], runtime); let removeScroll: (() => void) | undefined; const configurePosition = () => { const nativeScroll = targets.every(scrollsWithDocument); root.style.position = nativeScroll ? "absolute" : "fixed"; removeScroll?.(); - removeScroll = nativeScroll && !viewportAware && typeof IntersectionObserver !== "undefined" ? undefined : trackScroll(updateScroll); + removeScroll = + nativeScroll && !viewportAware && typeof IntersectionObserver !== "undefined" + ? undefined + : trackScroll(updateScroll); }; - const motion = typeof matchMedia === "undefined" ? null : matchMedia("(prefers-reduced-motion: reduce)"); + const motion = + typeof matchMedia === "undefined" ? null : matchMedia("(prefers-reduced-motion: reduce)"); let destroyed = false; - let removeDescription: (() => void) | undefined; - if (options.description) { - const text = document.createElement("span"); - text.className = "stet-description"; - text.textContent = options.description; - root.append(text); - removeDescription = describe(targets[targets.length - 1], text); + // Author intent stays separate from viewport/intersection culling: only + // show()/replay() clear it, and culling can never restore it. + let explicitlyHidden = options.visible === false; + const ownedAria = new Set(); + const attachAria = () => { + for (const link of ownedAria) link.attach(); + }; + const detachAria = () => { + for (const link of ownedAria) link.detach(); + }; + const own = (link: AriaLink) => { + if (destroyed) { + link.remove(); + return; + } + ownedAria.add(link); + // describe() attaches on creation; a hidden annotation must not expose it. + if (explicitlyHidden) link.detach(); + }; + // Removing an owned relationship must also drop it so a later show() cannot + // re-attach an ID whose text node is gone. + const disown = (link: AriaLink) => { + ownedAria.delete(link); + link.remove(); + }; + let descriptionNode: HTMLSpanElement | undefined; + let descriptionLink: AriaLink | undefined; + const syncDescription = () => { + if (runtime.description) { + if (!descriptionNode) { + descriptionNode = document.createElement("span"); + descriptionNode.className = "stet-description"; + root.append(descriptionNode); + } + if (descriptionNode.textContent !== runtime.description) + descriptionNode.textContent = runtime.description; + if (!descriptionLink) { + descriptionLink = describe(targets[targets.length - 1], descriptionNode); + own(descriptionLink); + } + } else if (descriptionNode) { + if (descriptionLink) { + disown(descriptionLink); + descriptionLink = undefined; + } + descriptionNode.remove(); + descriptionNode = undefined; + } + }; + syncDescription(); + const intersecting = new Map(targets.map((target) => [target, true])); + + // Clock-based reveal: progress comes from performance.now() at draw/tick time, + // so culling, detachment, or a suspended frame loop cannot pause completion. + // pathLength="1" normalizes every path so dashes need no getTotalLength(). + // Timing is resolved per operation so an update can only affect the next entrance. + let animation = resolveAnimation(runtime); + // At most one operation per handle: the delay plus reveal started by animated + // attach, show(), or replay(). Every operation settles exactly once. + interface Operation { + startedAt: number; + delay: number; + duration: number; + primed: boolean; + promise: Promise; + resolve: (result: StetAnimationResult) => void; + settled: boolean; } - let seed = options.seed ?? randomSeed(); - const visible = new Map(targets.map((target) => [target, true])); + let operation: Operation | null = null; + let revealFrame: number | undefined; + const clearRevealAttributes = () => { + for (const path of Array.from(svg.querySelectorAll("path"))) { + path.removeAttribute("pathLength"); + path.removeAttribute("stroke-dasharray"); + path.removeAttribute("stroke-dashoffset"); + } + }; + const setRevealProgress = (progress: number) => { + for (const path of Array.from(svg.querySelectorAll("path"))) { + path.setAttribute("pathLength", "1"); + path.setAttribute("stroke-dasharray", "1"); + path.setAttribute("stroke-dashoffset", String(1 - progress)); + } + }; + const cancelRevealFrame = () => { + if (revealFrame !== undefined) { + cancelAnimationFrame(revealFrame); + revealFrame = undefined; + } + }; + // Null the operation before resolving so a superseding replay starts after the + // old result is observable and a settled operation can never be re-settled. + const settleOperation = (status: StetAnimationResult["status"]) => { + const current = operation; + if (!current || current.settled) return; + current.settled = true; + operation = null; + current.resolve({ status }); + }; + const stopOperation = (status: StetAnimationResult["status"]) => { + settleOperation(status); + cancelRevealFrame(); + clearRevealAttributes(); + }; + const beginOperation = (): Promise => { + let resolve!: (result: StetAnimationResult) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + operation = { + startedAt: performance.now(), + delay: animation.delay, + duration: animation.duration, + primed: false, + promise, + resolve, + settled: false, + }; + if (svg.querySelector("path")) { + setRevealProgress(0); + operation.primed = true; + } + if (revealFrame === undefined) revealFrame = requestAnimationFrame(revealTick); + return promise; + }; + const applyReveal = () => { + if (!operation) return; + const elapsed = performance.now() - operation.startedAt - operation.delay; + const progress = + motion?.matches || operation.duration <= 0 + ? 1 + : !operation.primed || elapsed <= 0 + ? 0 + : Math.min(1, elapsed / operation.duration); + operation.primed = true; + setRevealProgress(progress); + if (progress >= 1) stopOperation("finished"); + }; + const revealTick = () => { + revealFrame = undefined; + if (destroyed) return; + applyReveal(); + if (operation) revealFrame = requestAnimationFrame(revealTick); + }; + // Zero duration and reduced motion reach the final state without an operation. + const startOperation = (): Promise | null => + animation.enabled && + strokedRevealPrimitives.has(primitive) && + !motion?.matches && + animation.duration > 0 + ? beginOperation() + : null; + let hasDrawn = false; let lastRects: DOMRect[] = []; - const draw = () => { + const drawFrame = (force = false) => { if (destroyed) return; lastRects = targets.map((target) => target.getBoundingClientRect()); - root.hidden = targets.some((target, index) => { - const rect = lastRects[index]; - return !target.isConnected || !target.getClientRects().length || !visible.get(target) || - rect.bottom < 0 || rect.right < 0 || rect.top > innerHeight || rect.left > innerWidth; - }); - if (root.hidden && hasDrawn) return; + root.hidden = + explicitlyHidden || + targets.some((target, index) => { + const rect = lastRects[index]; + return ( + !target.isConnected || + !target.getClientRects().length || + !intersecting.get(target) || + rect.bottom < 0 || + rect.right < 0 || + rect.top > innerHeight || + rect.left > innerWidth + ); + }); + if (root.hidden && hasDrawn && !force) return; const next = document.createElementNS(SVG_NS, "svg"); render(root, next, { - seed, - roughness: options.roughness ?? 1, - boil: motion?.matches ? 0 : (options.boil ?? 0), + rough: { + seed, + roughness: runtime.roughness ?? 1, + boil: motion?.matches ? 0 : (runtime.boil ?? 0), + }, + options: runtime, + own, + disown, }); svg.setAttribute("viewBox", next.getAttribute("viewBox") ?? "0 0 1 1"); if (svg.innerHTML !== next.innerHTML) svg.replaceChildren(...Array.from(next.childNodes)); hasDrawn = true; + applyReveal(); }; + const draw = () => drawFrame(); const updateScroll = () => { if (destroyed) return; // Fixed targets often do not move at all when the page scrolls. - if (hasDrawn && targets.every((target, index) => { - const rect = target.getBoundingClientRect(); - const last = lastRects[index]; - return target.isConnected && rect.left === last.left && rect.top === last.top && - rect.width === last.width && rect.height === last.height; - })) return; + if ( + hasDrawn && + targets.every((target, index) => { + const rect = target.getBoundingClientRect(); + const last = lastRects[index]; + return ( + target.isConnected && + rect.left === last.left && + rect.top === last.top && + rect.width === last.width && + rect.height === last.height + ); + }) + ) + return; draw(); }; configurePosition(); + if (!explicitlyHidden) startOperation(); draw(); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(draw); @@ -258,51 +595,139 @@ export function mount( observer?.observe(target); if (target.parentElement) observer?.observe(target.parentElement); }); - const intersection = typeof IntersectionObserver === "undefined" ? null : new IntersectionObserver((entries) => { - for (const entry of entries) visible.set(entry.target, entry.isIntersecting); - draw(); - }); + const intersection = + typeof IntersectionObserver === "undefined" + ? null + : new IntersectionObserver((entries) => { + for (const entry of entries) intersecting.set(entry.target, entry.isIntersecting); + draw(); + }); targets.forEach((target) => intersection?.observe(target)); const resketch = (nextSeed?: number) => { seed = nextSeed ?? randomSeed(); draw(); }; - const onPointer = () => { if (!motion?.matches) resketch(); }; - if (options.resketchOnHover) { + const onPointer = () => { + if (!motion?.matches) resketch(); + }; + const bindHover = (on: boolean) => { targets.forEach((target) => { - target.addEventListener("pointerenter", onPointer); - target.addEventListener("pointerdown", onPointer); + if (on) { + target.addEventListener("pointerenter", onPointer); + target.addEventListener("pointerdown", onPointer); + } else { + target.removeEventListener("pointerenter", onPointer); + target.removeEventListener("pointerdown", onPointer); + } }); - } - const onResize = () => { configurePosition(); draw(); }; + }; + let hoverBound = runtime.resketchOnHover === true; + if (hoverBound) bindHover(true); + const onResize = () => { + configurePosition(); + draw(); + }; + // Reduced motion mid-operation jumps to the final visible frame and settles + // finished; applyReveal forces progress 1 so this also works while culled. + const onMotionChange = () => { + if (motion?.matches && operation) applyReveal(); + draw(); + }; window.addEventListener("resize", onResize); - motion?.addEventListener?.("change", draw); + motion?.addEventListener?.("change", onMotionChange); document.fonts?.addEventListener("loadingdone", draw); void document.fonts?.ready.then(draw); - return { + const show = (): Promise => { + if (destroyed) return Promise.resolve({ status: "cancelled" }); + // Join the in-flight operation exactly, whether it came from attach or replay. + if (operation) return operation.promise; + const wasHidden = explicitlyHidden; + explicitlyHidden = false; + attachAria(); + // Only leaving explicit hiding starts an entrance; a settled-visible show is + // a distinct fulfilled result and must not redraw or replay. + if (wasHidden) { + const started = startOperation(); + draw(); + if (started) return started; + } + return Promise.resolve({ status: "finished" }); + }; + const hide = (): void => { + if (destroyed || explicitlyHidden) return; + explicitlyHidden = true; + detachAria(); + stopOperation("cancelled"); + draw(); + }; + const replay = (): Promise => { + if (destroyed) return Promise.resolve({ status: "cancelled" }); + // Replay always restores visibility/ARIA and preserves the current seed. + explicitlyHidden = false; + attachAria(); + // Supersede the prior operation before the replacement can settle. + stopOperation("cancelled"); + const started = startOperation(); + draw(); + return started ?? Promise.resolve({ status: "finished" }); + }; + + const update = (next: StetRuntimeOptions): void => { + if (destroyed) return; + // `visible` and adapter-only metadata must never enter this path. + const { visible: _visible, onHandle: _onHandle, ...rest } = next as Record; + const normalized = rest as unknown as O; + // Validate before any DOM or ARIA mutation. + validate?.(normalized); + assertRevealSupport(primitive, normalized); + if (sameRuntimeOptions(runtime, normalized)) return; + // A changed seed redraws at the current progress; an unchanged one keeps + // whatever resketch() last produced. + if (normalized.seed !== lastSeedOption) { + seed = normalized.seed ?? randomSeed(); + lastSeedOption = normalized.seed; + } + runtime = normalized; + animation = resolveAnimation(runtime); + const hover = runtime.resketchOnHover === true; + if (hover !== hoverBound) { + hoverBound = hover; + bindHover(hover); + } + syncDescription(); + inheritTheme(root, targets[0], runtime); + drawFrame(true); + }; + + const handle: StetHandle & { [updateCapability]: UpdateCapability } = { + show, + hide, + replay, resketch, refresh() { if (destroyed) return; - inheritTheme(root, targets[0], options); + inheritTheme(root, targets[0], runtime); configurePosition(); draw(); }, destroy() { if (destroyed) return; destroyed = true; - removeDescription?.(); + // Stop and settle any operation cancelled before tearing down. + stopOperation("cancelled"); + for (const link of ownedAria) link.remove(); + ownedAria.clear(); observer?.disconnect(); intersection?.disconnect(); - targets.forEach((target) => { - target.removeEventListener("pointerenter", onPointer); - target.removeEventListener("pointerdown", onPointer); - }); + bindHover(false); window.removeEventListener("resize", onResize); removeScroll?.(); - motion?.removeEventListener?.("change", draw); + motion?.removeEventListener?.("change", onMotionChange); document.fonts?.removeEventListener("loadingdone", draw); root.remove(); }, + [updateCapability]: { update }, }; + return { handle, own }; } diff --git a/src/primitives.ts b/src/primitives.ts index c1cfed3..f912150 100644 --- a/src/primitives.ts +++ b/src/primitives.ts @@ -6,12 +6,21 @@ import { describe, mount, place, + type AriaLink, type StetHandle, type StetOptions, } from "./mount.js"; -import { arrowGeometry, markerWash, roughArrow, roughCheckmark, roughEllipse, roughLine, roughPaper } from "./rough.js"; +import { + arrowGeometry, + markerWash, + roughArrow, + roughCheckmark, + roughEllipse, + roughLine, + roughPaper, +} from "./rough.js"; -export type { StetHandle, StetOptions } from "./mount.js"; +export type { StetAnimationResult, StetHandle, StetOptions } from "./mount.js"; export interface StickyOptions extends StetOptions { text: string; @@ -36,9 +45,8 @@ type SingleKind = "circle" | "underline" | "highlight"; function single(element: Element, kind: SingleKind, options: StetOptions = {}): StetHandle { assertElement(element); - options = { ...options }; - return mount([element], kind, options, (root, svg, rough) => { - const padding = options.padding ?? (kind === "underline" ? 3 : 5); + return mount([element], kind, options, (root, svg, { rough, options: cfg }) => { + const padding = cfg.padding ?? (kind === "underline" ? 3 : 5); const rects = clientBoxes(element, kind !== "circle"); const outer = bounds(rects); place( @@ -97,7 +105,7 @@ function single(element: Element, kind: SingleKind, options: StetOptions = {}): ); } }); - }); + }).handle; } export function circle(element: Element, options: StetOptions = {}): StetHandle { @@ -114,48 +122,61 @@ export function highlight(element: Element, options: StetOptions = {}): StetHand export function mark(element: Element, kind: MarkKind, options: StetOptions = {}): StetHandle { assertElement(element); - options = { ...options }; if (kind !== "right" && kind !== "wrong") throw new TypeError('stet: mark kind must be "right" or "wrong"'); - return mount([element], "mark", options, (root, svg, rough) => { - const rect = element.getBoundingClientRect(); - const padding = options.padding ?? 4; - place( - root, - svg, - rect.left - padding, - rect.top - padding, - rect.width + padding * 2, - rect.height + padding * 2, - ); - const width = rect.width + padding; - const height = rect.height + padding; - if (kind === "right") { - createPath( - svg, - "stet-mark stet-mark--right", - (o) => roughCheckmark( - rect.width < 40 ? padding + rect.width / 2 - 12.5 : rect.width + padding + 3, - rect.width < 40 ? padding - 26 : padding - 7, - 25, 23, o, - ), - rough, - ); - } else { - createPath( - svg, - "stet-mark stet-mark--wrong", - (o) => roughLine(padding / 2, padding / 2, width, height, o), - rough, - ); - createPath( + const runtime = { ...options, kind }; + return mount( + [element], + "mark", + runtime, + (root, svg, { rough, options: cfg }) => { + const rect = element.getBoundingClientRect(); + const padding = cfg.padding ?? 4; + place( + root, svg, - "stet-mark stet-mark--wrong", - (o) => roughLine(width, padding / 2, padding / 2, height, { ...o, seed: o.seed + 1 }), - rough, + rect.left - padding, + rect.top - padding, + rect.width + padding * 2, + rect.height + padding * 2, ); - } - }); + const width = rect.width + padding; + const height = rect.height + padding; + if (cfg.kind === "right") { + createPath( + svg, + "stet-mark stet-mark--right", + (o) => + roughCheckmark( + rect.width < 40 ? padding + rect.width / 2 - 12.5 : rect.width + padding + 3, + rect.width < 40 ? padding - 26 : padding - 7, + 25, + 23, + o, + ), + rough, + ); + } else { + createPath( + svg, + "stet-mark stet-mark--wrong", + (o) => roughLine(padding / 2, padding / 2, width, height, o), + rough, + ); + createPath( + svg, + "stet-mark stet-mark--wrong", + (o) => roughLine(width, padding / 2, padding / 2, height, { ...o, seed: o.seed + 1 }), + rough, + ); + } + }, + false, + (next) => { + if (next.kind !== "right" && next.kind !== "wrong") + throw new TypeError('stet: mark kind must be "right" or "wrong"'); + }, + ).handle; } function insetPoint(from: DOMRect, to: DOMRect, gap: number): [number, number] { @@ -178,61 +199,89 @@ function insetPoint(from: DOMRect, to: DOMRect, gap: number): [number, number] { export function arrow(from: Element, to: Element, options: ArrowOptions = {}): StetHandle { assertElement(from, "from element"); assertElement(to, "to element"); - options = { ...options }; let label: HTMLSpanElement | undefined; - const handle = mount([from, to], "arrow", options, (root, svg, rough) => { - const fromRect = from.getBoundingClientRect(); - const toRect = to.getBoundingClientRect(); - const start = insetPoint(fromRect, toRect, 4); - const end = insetPoint(toRect, fromRect, 4); - const margin = 16; - const left = Math.min(start[0], end[0]) - margin; - const top = Math.min(start[1], end[1]) - margin; - const width = Math.abs(end[0] - start[0]) + margin * 2; - const height = Math.abs(end[1] - start[1]) + margin * 2; - place(root, svg, left, top, width, height); - createPath( - svg, - "stet-arrow", - (o) => roughArrow(start[0] - left, start[1] - top, end[0] - left, end[1] - top, o, options.curvature), - rough, - ); - if (options.label) { - label ??= document.createElement("span"); - label.className = "stet-label"; - label.textContent = options.label; - const { midpoint } = arrowGeometry(...start, ...end, options.curvature); - root.append(label); - const halfWidth = label.offsetWidth / 2, halfHeight = label.offsetHeight / 2; - const vertical = Math.abs(end[1] - start[1]) > Math.abs(end[0] - start[0]); - let x = midpoint[0] + (vertical ? halfWidth + 8 : 0); - let y = midpoint[1] - (vertical ? 0 : halfHeight + 8); - const overlaps = (r: DOMRect) => x + halfWidth > r.left && x - halfWidth < r.right && - y + halfHeight > r.top && y - halfHeight < r.bottom; - if (overlaps(fromRect) || overlaps(toRect)) { - if (vertical) { - x = Math.max(fromRect.right, toRect.right) + halfWidth + 8; - if (innerWidth - Math.max(fromRect.right, toRect.right) < Math.min(fromRect.left, toRect.left)) - x = Math.min(fromRect.left, toRect.left) - halfWidth - 8; - } else y = Math.min(fromRect.top, toRect.top) - halfHeight - 8; + let labelLink: AriaLink | undefined; + const { handle } = mount( + [from, to], + "arrow", + options, + (root, svg, { rough, options: cfg, own, disown }) => { + const fromRect = from.getBoundingClientRect(); + const toRect = to.getBoundingClientRect(); + const start = insetPoint(fromRect, toRect, 4); + const end = insetPoint(toRect, fromRect, 4); + const margin = 16; + const left = Math.min(start[0], end[0]) - margin; + const top = Math.min(start[1], end[1]) - margin; + const width = Math.abs(end[0] - start[0]) + margin * 2; + const height = Math.abs(end[1] - start[1]) + margin * 2; + place(root, svg, left, top, width, height); + createPath( + svg, + "stet-arrow", + (o) => + roughArrow( + start[0] - left, + start[1] - top, + end[0] - left, + end[1] - top, + o, + cfg.curvature, + ), + rough, + ); + if (cfg.label) { + if (!label) { + label = document.createElement("span"); + label.className = "stet-label"; + label.textContent = cfg.label; + root.append(label); + } else if (label.textContent !== cfg.label) { + label.textContent = cfg.label; + } + if (!labelLink) { + labelLink = describe(to, label); + own(labelLink); + } + const { midpoint } = arrowGeometry(...start, ...end, cfg.curvature); + const halfWidth = label.offsetWidth / 2, + halfHeight = label.offsetHeight / 2; + const vertical = Math.abs(end[1] - start[1]) > Math.abs(end[0] - start[0]); + let x = midpoint[0] + (vertical ? halfWidth + 8 : 0); + let y = midpoint[1] - (vertical ? 0 : halfHeight + 8); + const overlaps = (r: DOMRect) => + x + halfWidth > r.left && + x - halfWidth < r.right && + y + halfHeight > r.top && + y - halfHeight < r.bottom; + if (overlaps(fromRect) || overlaps(toRect)) { + if (vertical) { + x = Math.max(fromRect.right, toRect.right) + halfWidth + 8; + if ( + innerWidth - Math.max(fromRect.right, toRect.right) < + Math.min(fromRect.left, toRect.left) + ) + x = Math.min(fromRect.left, toRect.left) - halfWidth - 8; + } else y = Math.min(fromRect.top, toRect.top) - halfHeight - 8; + } + x += cfg.labelOffsetX ?? 0; + y += cfg.labelOffsetY ?? 0; + x = Math.max(halfWidth + 8, Math.min(x, innerWidth - halfWidth - 8)); + y = Math.max(halfHeight + 8, Math.min(y, innerHeight - halfHeight - 8)); + label.style.left = `${x - left}px`; + label.style.top = `${y - top}px`; + } else if (label) { + if (labelLink) { + disown(labelLink); + labelLink = undefined; + } + label.remove(); + label = undefined; } - x += options.labelOffsetX ?? 0; - y += options.labelOffsetY ?? 0; - x = Math.max(halfWidth + 8, Math.min(x, innerWidth - halfWidth - 8)); - y = Math.max(halfHeight + 8, Math.min(y, innerHeight - halfHeight - 8)); - label.style.left = `${x - left}px`; - label.style.top = `${y - top}px`; - } - }, Boolean(options.label)); - const removeDescription = label ? describe(to, label) : undefined; - return { - resketch: handle.resketch, - refresh: handle.refresh, - destroy() { - removeDescription?.(); - handle.destroy(); }, - }; + true, + ); + return handle; } function stickySide( @@ -250,58 +299,64 @@ function stickySide( }; if (requested && requested !== "auto" && room[requested] >= 0) return requested; const sides = ["right", "bottom", "left", "top"] as const; - return sides.find((side) => room[side] >= 0) ?? - sides.reduce((best, side) => room[side] > room[best] ? side : best); + return ( + sides.find((side) => room[side] >= 0) ?? + sides.reduce((best, side) => (room[side] > room[best] ? side : best)) + ); } export function sticky(element: Element, options: StickyOptions): StetHandle { assertElement(element); if (!options || typeof options.text !== "string") throw new TypeError("stet: sticky text is required"); - options = { ...options }; let text: HTMLSpanElement | undefined; - const handle = mount([element], "sticky", options, (root, svg, rough) => { - const rect = element.getBoundingClientRect(); - const width = Math.max(1, Math.min(176, innerWidth - 24)); - text ??= document.createElement("span"); - text.className = "stet-sticky-text"; - if (text.textContent !== options.text) text.textContent = options.text; - root.style.width = `${width}px`; - root.append(text); - const height = Math.max(88, text.offsetHeight + 30); - const gap = options.padding ?? 14; - const side = stickySide(rect, options.side, width, height, gap); - let left = - side === "left" - ? rect.left - width - gap - : side === "right" - ? rect.right + gap - : rect.left + (rect.width - width) / 2; - let top = - side === "top" - ? rect.top - height - gap - : side === "bottom" - ? rect.bottom + gap - : rect.top + (rect.height - height) / 2; - left += options.offsetX ?? 0; - top += options.offsetY ?? 0; - left = Math.max(12, Math.min(left, innerWidth - width - 12)); - top = Math.max(12, Math.min(top, innerHeight - height - 12)); - place(root, svg, left, top, width, height); - createPath( - svg, - "stet-sticky-paper", - (o) => roughPaper(3, 3, width - 6, height - 6, o), - rough, - ); - }, true); - const removeDescription = describe(element, text!); - return { - resketch: handle.resketch, - refresh: handle.refresh, - destroy() { - removeDescription(); - handle.destroy(); + let textLink: AriaLink | undefined; + const { handle } = mount( + [element], + "sticky", + options, + (root, svg, { rough, options: cfg, own }) => { + const rect = element.getBoundingClientRect(); + const width = Math.max(1, Math.min(176, innerWidth - 24)); + text ??= document.createElement("span"); + text.className = "stet-sticky-text"; + if (text.textContent !== cfg.text) text.textContent = cfg.text; + root.append(text); + if (!textLink) { + textLink = describe(element, text); + own(textLink); + } + const height = Math.max(88, text.offsetHeight + 30); + const gap = cfg.padding ?? 14; + const side = stickySide(rect, cfg.side, width, height, gap); + let left = + side === "left" + ? rect.left - width - gap + : side === "right" + ? rect.right + gap + : rect.left + (rect.width - width) / 2; + let top = + side === "top" + ? rect.top - height - gap + : side === "bottom" + ? rect.bottom + gap + : rect.top + (rect.height - height) / 2; + left += cfg.offsetX ?? 0; + top += cfg.offsetY ?? 0; + left = Math.max(12, Math.min(left, innerWidth - width - 12)); + top = Math.max(12, Math.min(top, innerHeight - height - 12)); + place(root, svg, left, top, width, height); + createPath( + svg, + "stet-sticky-paper", + (o) => roughPaper(3, 3, width - 6, height - 6, o), + rough, + ); }, - }; + true, + (next) => { + if (typeof next.text !== "string") throw new TypeError("stet: sticky text is required"); + }, + ); + return handle; } diff --git a/tests/agent/schema.test.ts b/tests/agent/schema.test.ts index 8ea7a43..e97b28f 100644 --- a/tests/agent/schema.test.ts +++ b/tests/agent/schema.test.ts @@ -1,60 +1,156 @@ // @vitest-environment node -import { expect, it } from 'vitest'; -import Ajv from 'ajv'; -import { readFileSync } from 'node:fs'; +import { expect, it } from "vitest"; +import Ajv from "ajv"; +import { readFileSync } from "node:fs"; // @ts-expect-error Agent tooling is intentionally outside the browser TS build. -import { validatePlan } from '../../agent/validate.mjs'; -const read = (p: string) => JSON.parse(readFileSync(p, 'utf8')); -const schema = read('agent/schemas/annotation-plan.schema.json'); +import { validatePlan } from "../../agent/validate.mjs"; +const read = (p: string) => JSON.parse(readFileSync(p, "utf8")); +const schema = read("agent/schemas/annotation-plan.schema.json"); const ajv = new Ajv({ allErrors: true }); const validate = ajv.compile(schema); -const fixtures = read('agent/evals/plans.json'); -for (const fixture of fixtures) it(`plan fixture: ${fixture.name}`, () => { - const result = validatePlan(fixture.plan); - expect(result.ok).toBe(fixture.valid); - expect(validate(fixture.plan)).toBe(fixture.schemaValid ?? fixture.valid); - if (fixture.path) expect(result.errors.some((e: {path: string}) => e.path === fixture.path)).toBe(true); -}); -it('capabilities match their published schema and expose package exports', () => { - const caps = read('agent/capabilities.json'); - expect(ajv.compile(read('agent/schemas/capabilities.schema.json'))(caps)).toBe(true); - const pkg = read('package.json'); expect(caps.package).toEqual({ name: pkg.name, version: pkg.version, exports: Object.keys(pkg.exports) }); +const fixtures = read("agent/evals/plans.json"); +for (const fixture of fixtures) + it(`plan fixture: ${fixture.name}`, () => { + const result = validatePlan(fixture.plan); + expect(result.ok).toBe(fixture.valid); + expect(validate(fixture.plan)).toBe(fixture.schemaValid ?? fixture.valid); + if (fixture.path) + expect(result.errors.some((e: { path: string }) => e.path === fixture.path)).toBe(true); + }); +it("capabilities match their published schema and expose package exports", () => { + const caps = read("agent/capabilities.json"); + expect(ajv.compile(read("agent/schemas/capabilities.schema.json"))(caps)).toBe(true); + const pkg = read("package.json"); + expect(caps.package).toEqual({ + name: pkg.name, + version: pkg.version, + exports: Object.keys(pkg.exports), + }); expect(pkg.dependencies).toBeUndefined(); }); -it('accepts every primitive/framework and rejects each incompatible option', () => { - const caps = read('agent/capabilities.json'); - const target = { strategy: 'id', file: 'index.html', locator: 'save', description: 'Save' }; - for (const framework of Object.keys(caps.frameworks)) for (const [primitive, meta] of Object.entries(caps.primitives)) { - const annotation = { id: primitive, primitive, targets: meta.targets.map(() => target), options: primitive === 'sticky' ? { text: 'Save first.' } : {}, ...(primitive === 'mark' ? { kind: 'right' } : {}) }; - const plan = { version: 1, framework, intent: 'explain', annotations: [annotation] }; - expect(validatePlan(plan).ok).toBe(true); - for (const [key,value] of Object.entries({ side: 'right', text: 'note', label: 'label', curvature: 0.1, imaginary: true })) { - if (key in meta.options.properties) continue; - expect(validatePlan({ ...plan, annotations: [{ ...annotation, options: { ...annotation.options, [key]: value } }] }).ok).toBe(false); +it("accepts every primitive/framework and rejects each incompatible option", () => { + const caps = read("agent/capabilities.json"); + const target = { strategy: "id", file: "index.html", locator: "save", description: "Save" }; + for (const framework of Object.keys(caps.frameworks)) + for (const [primitive, meta] of Object.entries(caps.primitives)) { + const annotation = { + id: primitive, + primitive, + targets: meta.targets.map(() => target), + options: primitive === "sticky" ? { text: "Save first." } : {}, + ...(primitive === "mark" ? { kind: "right" } : {}), + }; + const plan = { version: 1, framework, intent: "explain", annotations: [annotation] }; + expect(validatePlan(plan).ok).toBe(true); + for (const [key, value] of Object.entries({ + side: "right", + text: "note", + label: "label", + curvature: 0.1, + imaginary: true, + })) { + if (key in meta.options.properties) continue; + expect( + validatePlan({ + ...plan, + annotations: [{ ...annotation, options: { ...annotation.options, [key]: value } }], + }).ok, + ).toBe(false); + } } +}); +it("matches runtime animation validation and reveal support", () => { + const caps = read("agent/capabilities.json"); + const target = { strategy: "id", file: "index.html", locator: "save", description: "Save" }; + const plan = (primitive: string, options: Record) => ({ + version: 1, + framework: "vanilla", + intent: "reveal", + annotations: [ + { + id: primitive, + primitive, + targets: caps.primitives[primitive].targets.map(() => target), + options: { ...(primitive === "sticky" ? { text: "Note" } : {}), ...options }, + ...(primitive === "mark" ? { kind: "right" } : {}), + }, + ], + }); + + for (const primitive of ["circle", "underline"]) { + expect(validatePlan(plan(primitive, { animate: true })).ok).toBe(true); + expect(validatePlan(plan(primitive, { animationDuration: 0, animationDelay: 0 })).ok).toBe( + true, + ); + expect(caps.primitives[primitive].defaults).toMatchObject({ + animationDuration: 600, + animationDelay: 0, + }); + } + for (const primitive of ["highlight", "arrow", "sticky", "mark"]) { + expect(validatePlan(plan(primitive, { animate: true })).ok).toBe(false); + expect(validatePlan(plan(primitive, { animationDuration: 1 })).ok).toBe(false); + expect(validatePlan(plan(primitive, { animationDelay: 1 })).ok).toBe(false); + expect( + validatePlan(plan(primitive, { animate: false, animationDuration: 1, animationDelay: 1 })).ok, + ).toBe(true); + expect(caps.primitives[primitive].defaults.animationDuration).toBeUndefined(); + } + for (const value of [-1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + expect(validatePlan(plan("circle", { animationDuration: value })).ok).toBe(false); + expect(validatePlan(plan("circle", { animate: false, animationDelay: value })).ok).toBe(false); } }); -it('reports decorative and brittle targeting warnings without executing locators', () => { +it("reports decorative and brittle targeting warnings without executing locators", () => { const plan = fixtures[0].plan; - const a = { ...plan.annotations[0], options: {}, targets: [{ ...plan.annotations[0].targets[0], strategy: 'css', locator: 'button:nth-child(3)', rationale: 'Legacy page without stable attributes; verified unique.' }] }; + const a = { + ...plan.annotations[0], + options: {}, + targets: [ + { + ...plan.annotations[0].targets[0], + strategy: "css", + locator: "button:nth-child(3)", + rationale: "Legacy page without stable attributes; verified unique.", + }, + ], + }; const result = validatePlan({ ...plan, annotations: [a] }); - expect(result.ok).toBe(true); expect(result.warnings.map((w: {code: string}) => w.code)).toEqual(['BRITTLE_TARGET', 'DECORATIVE_ONLY']); + expect(result.ok).toBe(true); + expect(result.warnings.map((w: { code: string }) => w.code)).toEqual([ + "BRITTLE_TARGET", + "DECORATIVE_ONLY", + ]); }); -it('returns diagnostics for malformed JSON-shaped primitive values', () => { +it("returns diagnostics for malformed JSON-shaped primitive values", () => { for (const primitive of [null, 4, [], {}, { toString: null }]) { - const plan = structuredClone(fixtures[0].plan); plan.annotations[0].primitive = primitive; + const plan = structuredClone(fixtures[0].plan); + plan.annotations[0].primitive = primitive; expect(validatePlan(plan).ok).toBe(false); } }); -it('keeps bounded malformed mutations invalid and diagnostics deterministic', () => { +it("keeps bounded malformed mutations invalid and diagnostics deterministic", () => { const base = fixtures[0].plan; - const values = [null, false, 0, '', [], {}, JSON.parse('1e400'), '__proto__', 'constructor', { toString: null }]; + const values = [ + null, + false, + 0, + "", + [], + {}, + JSON.parse("1e400"), + "__proto__", + "constructor", + { toString: null }, + ]; const cases: unknown[] = [...values]; for (const value of values) { - for (const field of ['version', 'framework', 'annotations']) cases.push({ ...base, [field]: value }); - for (const field of ['id', 'primitive', 'targets', 'options']) cases.push({ ...base, annotations: [{ ...base.annotations[0], [field]: value }] }); + for (const field of ["version", "framework", "annotations"]) + cases.push({ ...base, [field]: value }); + for (const field of ["id", "primitive", "targets", "options"]) + cases.push({ ...base, annotations: [{ ...base.annotations[0], [field]: value }] }); cases.push({ ...base, annotations: [value] }); } for (const plan of cases) { @@ -63,27 +159,46 @@ it('keeps bounded malformed mutations invalid and diagnostics deterministic', () expect(result.ok, JSON.stringify(plan)).toBe(false); expect(result.errors.length).toBeGreaterThan(0); for (const error of result.errors) { - expect(error.path).toBeTruthy(); expect(error.code).toBeTruthy(); expect(error.message).toBeTruthy(); + expect(error.path).toBeTruthy(); + expect(error.code).toBeTruthy(); + expect(error.message).toBeTruthy(); } } expect(validatePlan(plan)).toEqual(result); } - for (const key of ['__proto__', 'constructor', 'a/b~c']) { + for (const key of ["__proto__", "constructor", "a/b~c"]) { const plan = JSON.parse(JSON.stringify(base)); Object.defineProperty(plan.annotations[0].options, key, { value: true, enumerable: true }); - expect(validatePlan(plan).errors).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'INVALID_OPTION' })])); + expect(validatePlan(plan).errors).toEqual( + expect.arrayContaining([expect.objectContaining({ code: "INVALID_OPTION" })]), + ); } }); -it('names missing sticky text, accepted mark kinds and installed plan version', () => { +it("names missing sticky text, accepted mark kinds and installed plan version", () => { const base = fixtures[0].plan; - const check = (a: object) => validatePlan({ ...base, annotations: [{ ...base.annotations[0], ...a }] }).errors; - expect(check({ primitive: 'sticky', options: {} })).toEqual(expect.arrayContaining([expect.objectContaining({ path: 'annotations[0].options.text', message: 'sticky: Add required field text.' })])); - expect(check({ primitive: 'mark', kind: 'bogus' }).some((e: any) => e.message.includes('right'))).toBe(true); - expect(validatePlan({ ...base, version: 2 }).errors[0].message).toContain('version 1'); + const check = (a: object) => + validatePlan({ ...base, annotations: [{ ...base.annotations[0], ...a }] }).errors; + expect(check({ primitive: "sticky", options: {} })).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "annotations[0].options.text", + message: "sticky: Add required field text.", + }), + ]), + ); + expect( + check({ primitive: "mark", kind: "bogus" }).some((e: any) => e.message.includes("right")), + ).toBe(true); + expect(validatePlan({ ...base, version: 2 }).errors[0].message).toContain("version 1"); }); -it('points malformed annotation objects at the annotation itself', () => { - for (const value of [null, [], 3, false]) expect(validatePlan({ ...fixtures[0].plan, annotations: [value] }).errors).toEqual([ - { path: 'annotations[0]', code: 'INVALID_VALUE', message: 'Expected an annotation object with id, primitive and targets.' }, - ]); +it("points malformed annotation objects at the annotation itself", () => { + for (const value of [null, [], 3, false]) + expect(validatePlan({ ...fixtures[0].plan, annotations: [value] }).errors).toEqual([ + { + path: "annotations[0]", + code: "INVALID_VALUE", + message: "Expected an annotation object with id, primitive and targets.", + }, + ]); }); diff --git a/tests/browser/reveal.spec.ts b/tests/browser/reveal.spec.ts new file mode 100644 index 0000000..ea5c2ce --- /dev/null +++ b/tests/browser/reveal.spec.ts @@ -0,0 +1,440 @@ +import { expect, test, type Page } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/examples/vanilla/"); + await page.evaluate(async () => { + await document.fonts.ready; + document.body.replaceChildren(); + document.body.style.cssText = "margin:0;min-height:3000px"; + }); +}); + +type Primitive = "circle" | "underline"; + +async function attach( + page: Page, + options: Record, + top = 350, + primitive: Primitive = "underline", +) { + return page.evaluate( + async ({ options, top, primitive }) => { + const target = document.createElement("button"); + target.id = "target"; + target.style.cssText = `position:absolute;left:40px;top:${top}px;width:140px;height:40px`; + document.body.append(target); + const { circle, underline } = await import("/dist/index.js"); + (window as any).stet = (primitive === "circle" ? circle : underline)(target, { + seed: 1, + ...options, + }); + const overlay = document.querySelector(`.stet-overlay--${primitive}`)!; + const path = overlay.querySelector("path")!; + return { + hidden: overlay.hidden, + offset: path.getAttribute("stroke-dashoffset"), + pathLength: path.getAttribute("pathLength"), + dasharray: path.getAttribute("stroke-dasharray"), + opacity: path.getAttribute("opacity"), + }; + }, + { options, top, primitive }, + ); +} + +const path = (page: Page, primitive: Primitive = "underline") => + page.locator(`.stet-overlay--${primitive} path`).first(); + +for (const primitive of ["circle", "underline"] as const) { + test(`${primitive} reveals with path-length dashes, no flash and no opacity`, async ({ + page, + }) => { + const initial = await attach( + page, + { animate: true, animationDelay: 200, animationDuration: 1500 }, + 350, + primitive, + ); + // Frame zero is fully hidden but already normalized for path-length draw-on. + expect(initial.hidden).toBe(false); + expect(initial.offset).toBe("1"); + expect(initial.pathLength).toBe("1"); + expect(initial.dasharray).toBe("1"); + expect(initial.opacity).toBeNull(); + // A real browser interpolates the dash offset between 1 and 0. + await expect + .poll(async () => Number(await path(page, primitive).getAttribute("stroke-dashoffset")), { + timeout: 3000, + }) + .toBeLessThan(1); + // The settled frame removes every reveal attribute and never touches opacity. + await expect + .poll(() => path(page, primitive).getAttribute("stroke-dashoffset"), { timeout: 4000 }) + .toBeNull(); + expect(await path(page, primitive).getAttribute("pathLength")).toBeNull(); + expect(await path(page, primitive).getAttribute("opacity")).toBeNull(); + }); + + test(`${primitive} refresh, resketch, scroll and resize redraw without replay`, async ({ + page, + }) => { + await attach(page, { animate: true, animationDuration: 1500 }, 350, primitive); + await page.waitForTimeout(500); + const before = Number(await path(page, primitive).getAttribute("stroke-dashoffset")); + expect(before).toBeGreaterThan(0); + expect(before).toBeLessThan(1); + await page.evaluate(() => { + window.scrollTo(0, 0); + window.dispatchEvent(new Event("scroll")); + window.dispatchEvent(new Event("resize")); + (window as any).stet.refresh(); + (window as any).stet.resketch(2); + }); + const after = Number(await path(page, primitive).getAttribute("stroke-dashoffset")); + // Restarting would jump the offset back toward 1; it can only advance. + expect(after).toBeLessThanOrEqual(before + 0.02); + await expect + .poll(() => path(page, primitive).getAttribute("stroke-dashoffset"), { timeout: 4000 }) + .toBeNull(); + }); + + test(`${primitive} initial visible:false stays settled and defers the reveal to show`, async ({ + page, + }) => { + const initial = await attach( + page, + { visible: false, animate: true, animationDelay: 200, animationDuration: 800 }, + 350, + primitive, + ); + expect(initial.hidden).toBe(true); + expect(initial.offset).toBeNull(); + await page.waitForTimeout(300); + expect(await path(page, primitive).getAttribute("stroke-dashoffset")).toBeNull(); + const shown = await page.evaluate((name) => { + (window as any).stet.show(); + const overlay = document.querySelector(`.stet-overlay--${name}`)!; + return { + hidden: overlay.hidden, + offset: overlay.querySelector("path")!.getAttribute("stroke-dashoffset"), + }; + }, primitive); + expect(shown.hidden).toBe(false); + expect(shown.offset).toBe("1"); + await expect + .poll(() => path(page, primitive).getAttribute("stroke-dashoffset"), { timeout: 4000 }) + .toBeNull(); + }); +} + +test("a culled reveal completes and shows its final frame once unculled", async ({ page }) => { + await page.setViewportSize({ width: 800, height: 600 }); + await attach(page, { animate: true, animationDuration: 400 }, 2000); + const overlay = page.locator(".stet-overlay--underline"); + await expect.poll(() => overlay.evaluate((node) => (node as HTMLElement).hidden)).toBe(true); + await page.waitForTimeout(800); + expect(await overlay.locator("path").first().getAttribute("stroke-dashoffset")).toBeNull(); + await page.evaluate(() => { + document.querySelector("#target")!.style.top = "100px"; + window.dispatchEvent(new Event("scroll")); + (window as any).stet.refresh(); + }); + await expect.poll(() => overlay.evaluate((node) => (node as HTMLElement).hidden)).toBe(false); + expect(await overlay.locator("path").first().getAttribute("stroke-dashoffset")).toBeNull(); +}); + +test("a detached reveal completes and reconnects at its final frame", async ({ page }) => { + await attach(page, { animate: true, animationDuration: 400 }); + const overlay = page.locator(".stet-overlay--underline"); + await page.evaluate(() => { + (window as any).target = document.querySelector("#target")!; + (window as any).target.remove(); + (window as any).stet.refresh(); + }); + await expect.poll(() => overlay.evaluate((node) => (node as HTMLElement).hidden)).toBe(true); + await page.waitForTimeout(800); + expect(await overlay.locator("path").first().getAttribute("stroke-dashoffset")).toBeNull(); + await page.evaluate(() => { + document.body.prepend((window as any).target); + (window as any).stet.refresh(); + }); + await expect.poll(() => overlay.evaluate((node) => (node as HTMLElement).hidden)).toBe(false); + expect(await overlay.locator("path").first().getAttribute("stroke-dashoffset")).toBeNull(); +}); + +for (const primitive of ["circle", "underline"] as const) { + test(`${primitive} settles immediately under initial reduced motion`, async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + const initial = await attach(page, { animate: true, animationDuration: 800 }, 350, primitive); + expect(initial.hidden).toBe(false); + expect(initial.offset).toBeNull(); + expect(initial.pathLength).toBeNull(); + }); + + test(`static ${primitive} stays free of reveal attributes`, async ({ page }) => { + const initial = await attach(page, {}, 350, primitive); + expect(initial.offset).toBeNull(); + expect(initial.pathLength).toBeNull(); + expect(initial.dasharray).toBeNull(); + await page.evaluate(() => (window as any).stet.refresh()); + expect(await path(page, primitive).getAttribute("stroke-dashoffset")).toBeNull(); + }); +} + +// CORE-05: real-browser interruption cases. The unit matrix covers the state +// table; these prove the same ordering against a real compositor clock. +// CORE-06: ambient motion composition. These run against a real compositor +// clock so live media changes, hover resketch and boil cross-fades are observed, +// not simulated. +test("live reduced motion during the delay settles finished at the final frame", async ({ + page, +}) => { + await attach(page, { animate: true, animationDelay: 5000, animationDuration: 2000 }); + await page.evaluate(() => { + (window as any).revealStatus = (window as any).stet + .show() + .then((result: { status: string }) => result.status); + }); + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect + .poll(() => path(page).getAttribute("stroke-dashoffset"), { timeout: 2000 }) + .toBeNull(); + expect(await path(page).getAttribute("pathLength")).toBeNull(); + await expect.poll(() => page.evaluate(() => (window as any).revealStatus)).toBe("finished"); +}); + +test("live reduced motion during the reveal settles finished and drops boil", async ({ page }) => { + await attach(page, { animate: true, animationDuration: 5000, boil: 0.4 }); + await expect(page.locator(".stet-boil")).toHaveCount(3); + await page.waitForTimeout(300); + expect(Number(await path(page).getAttribute("stroke-dashoffset"))).toBeLessThan(1); + await page.evaluate(() => { + (window as any).revealStatus = (window as any).stet + .show() + .then((result: { status: string }) => result.status); + }); + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect(page.locator(".stet-boil")).toHaveCount(0); + expect(await path(page).getAttribute("stroke-dashoffset")).toBeNull(); + await expect.poll(() => page.evaluate(() => (window as any).revealStatus)).toBe("finished"); +}); + +test("hover resketch during reveal keeps the operation and never replays", async ({ page }) => { + await attach(page, { animate: true, animationDuration: 4000, boil: 0.3, resketchOnHover: true }); + await page.evaluate(() => { + (window as any).op = (window as any).stet.show(); + }); + await page.waitForTimeout(400); + const before = Number(await path(page).getAttribute("stroke-dashoffset")); + expect(before).toBeLessThan(1); + const beforeD = await path(page).getAttribute("d"); + await page.locator("#target").hover(); + expect(Number(await path(page).getAttribute("stroke-dashoffset"))).toBeLessThanOrEqual( + before + 0.01, + ); + await expect.poll(() => path(page).getAttribute("d")).not.toBe(beforeD); + expect(await page.evaluate(() => (window as any).stet.show() === (window as any).op)).toBe(true); + await expect + .poll(() => path(page).getAttribute("stroke-dashoffset"), { timeout: 6000 }) + .toBeNull(); + expect(await page.evaluate(() => (window as any).op)).toEqual({ status: "finished" }); +}); + +test("boil variants share reveal progress with no initial or final flash", async ({ page }) => { + const frameZero = await attach(page, { animate: true, animationDuration: 1500, boil: 0.4 }); + // Frame zero is sampled inside the attach task, before the first reveal tick. + expect(frameZero.offset).toBe("1"); + expect(frameZero.pathLength).toBe("1"); + expect(frameZero.dasharray).toBe("1"); + expect(frameZero.opacity).toBeNull(); + const sample = () => + page.evaluate(() => + [...document.querySelectorAll(".stet-overlay--underline path")].map( + (node) => ({ + offset: node.getAttribute("stroke-dashoffset"), + pathLength: node.getAttribute("pathLength"), + dasharray: node.getAttribute("stroke-dasharray"), + opacity: node.getAttribute("opacity"), + }), + ), + ); + const initial = await sample(); + expect(initial).toHaveLength(3); + // All boil variants carry the same reveal progress; none is missing state. + expect(new Set(initial.map((variant) => variant.offset)).size).toBe(1); + for (const variant of initial) { + expect(Number(variant.offset)).toBeLessThanOrEqual(1); + expect(Number(variant.offset)).toBeGreaterThan(0.99); + expect(variant.pathLength).toBe("1"); + expect(variant.dasharray).toBe("1"); + expect(variant.opacity).toBeNull(); + } + await page.waitForTimeout(300); + for (const variant of await sample()) { + expect(Number(variant.offset)).toBeLessThanOrEqual(1); + expect(Number(variant.offset)).toBeGreaterThanOrEqual(0); + expect(variant.pathLength).toBe("1"); + expect(variant.opacity).toBeNull(); + } + await expect + .poll(async () => (await sample()).every((variant) => variant.offset === null), { + timeout: 4000, + }) + .toBe(true); + for (const variant of await sample()) { + expect(variant.offset).toBeNull(); + expect(variant.pathLength).toBeNull(); + expect(variant.opacity).toBeNull(); + } +}); + +test("fonts, theme, resize and scroll redraw at current progress without replay", async ({ + page, +}) => { + await attach(page, { animate: true, animationDuration: 4000 }); + await page.waitForTimeout(400); + const before = Number(await path(page).getAttribute("stroke-dashoffset")); + expect(before).toBeLessThan(1); + await page.evaluate(() => { + document.fonts.dispatchEvent(new Event("loadingdone")); + document.querySelector("#target")!.style.setProperty("--stet-stroke", "#123456"); + window.dispatchEvent(new Event("resize")); + window.dispatchEvent(new Event("scroll")); + (window as any).stet.refresh(); + (window as any).stet.resketch(2); + }); + // Restarting would jump the offset back toward 1; it can only advance. + expect(Number(await path(page).getAttribute("stroke-dashoffset"))).toBeLessThanOrEqual( + before + 0.02, + ); + await expect + .poll(() => path(page).getAttribute("stroke-dashoffset"), { timeout: 6000 }) + .toBeNull(); +}); + +test("a culled reveal finishes finished when reduced motion activates offscreen", async ({ + page, +}) => { + await page.setViewportSize({ width: 800, height: 600 }); + await attach(page, { animate: true, animationDuration: 5000 }, 2000); + const overlay = page.locator(".stet-overlay--underline"); + await expect.poll(() => overlay.evaluate((node) => (node as HTMLElement).hidden)).toBe(true); + await page.evaluate(() => { + (window as any).revealStatus = (window as any).stet + .show() + .then((result: { status: string }) => result.status); + }); + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect.poll(() => page.evaluate(() => (window as any).revealStatus)).toBe("finished"); + expect(await overlay.locator("path").first().getAttribute("stroke-dashoffset")).toBeNull(); +}); + +test("reduced motion yields one stable variant for deterministic capture", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await attach(page, { animate: true, animationDuration: 4000, boil: 0.4 }); + const overlay = page.locator(".stet-overlay--underline"); + await expect(overlay.locator(".stet-boil")).toHaveCount(0); + await expect(overlay.locator("path")).toHaveCount(1); + expect(await overlay.locator("path").first().getAttribute("stroke-dashoffset")).toBeNull(); + const first = await overlay.screenshot(); + await page.waitForTimeout(250); + const second = await overlay.screenshot(); + expect(second.equals(first)).toBe(true); + expect(await page.evaluate(() => document.getAnimations().length)).toBe(0); +}); + +for (const primitive of ["circle", "underline"] as const) { + test(`${primitive} show joins an in-flight reveal with the same promise object`, async ({ + page, + }) => { + await attach( + page, + { animate: true, animationDelay: 200, animationDuration: 1200 }, + 350, + primitive, + ); + const result = await page.evaluate(async () => { + const stet = (window as any).stet; + const first = stet.show(); + const second = stet.show(); + return { same: first === second, status: (await first).status }; + }); + expect(result.same).toBe(true); + expect(result.status).toBe("finished"); + }); + + test(`${primitive} replay cancels the in-flight operation before the replacement settles`, async ({ + page, + }) => { + await attach(page, { animate: true, animationDuration: 1500 }, 350, primitive); + const result = await page.evaluate(async () => { + const stet = (window as any).stet; + const order: string[] = []; + const old = stet.show(); + old.then((value: { status: string }) => order.push(value.status)); + const next = stet.replay(); + next.then((value: { status: string }) => order.push(value.status)); + const joined = stet.show() === next; + return { order, joined, old: await old, next: await next }; + }); + expect(result.joined).toBe(true); + expect(result.old).toEqual({ status: "cancelled" }); + expect(result.next).toEqual({ status: "finished" }); + expect(result.order).toEqual(["cancelled", "finished"]); + }); + + test(`${primitive} hide cancels an in-flight operation exactly once and stays hidden`, async ({ + page, + }) => { + await attach(page, { animate: true, animationDuration: 1500 }, 350, primitive); + const result = await page.evaluate(async (name) => { + const stet = (window as any).stet; + const op = stet.show(); + let settlements = 0; + op.then(() => settlements++); + stet.hide(); + stet.hide(); + const status = (await op).status; + await new Promise((resolve) => setTimeout(resolve, 1700)); + const overlay = document.querySelector(`.stet-overlay--${name}`)!; + return { status, settlements, hidden: overlay.hidden }; + }, primitive); + expect(result.status).toBe("cancelled"); + expect(result.settlements).toBe(1); + expect(result.hidden).toBe(true); + expect(await path(page, primitive).getAttribute("stroke-dashoffset")).toBeNull(); + }); + + test(`${primitive} destroy cancels an in-flight operation and no-ops afterwards`, async ({ + page, + }) => { + await attach(page, { animate: true, animationDuration: 1500 }, 350, primitive); + const result = await page.evaluate(async (name) => { + const stet = (window as any).stet; + const op = stet.show(); + stet.destroy(); + const cancelled = (await op).status; + const showPromise = stet.show(); + const replayPromise = stet.replay(); + const distinct = showPromise !== replayPromise; + const show = await showPromise; + const replay = await replayPromise; + stet.hide(); + stet.refresh(); + stet.resketch(3); + stet.destroy(); + return { + cancelled, + distinct, + show, + replay, + overlay: document.querySelector(`.stet-overlay--${name}`), + }; + }, primitive); + expect(result.cancelled).toBe("cancelled"); + expect(result.distinct).toBe(true); + expect(result.show).toEqual({ status: "cancelled" }); + expect(result.replay).toEqual({ status: "cancelled" }); + expect(result.overlay).toBeNull(); + }); +} diff --git a/tests/browser/scroll.spec.ts b/tests/browser/scroll.spec.ts index a2d03f7..bec26d4 100644 --- a/tests/browser/scroll.spec.ts +++ b/tests/browser/scroll.spec.ts @@ -10,10 +10,14 @@ test.beforeEach(async ({ page }) => { }); for (const positionedBody of [false, true]) { - test(`page marks scroll synchronously without SVG work (positioned body: ${positionedBody})`, async ({ page }) => { + test(`page marks scroll synchronously without SVG work (positioned body: ${positionedBody})`, async ({ + page, + }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.evaluate(async (positioned) => { - if (positioned) document.body.style.cssText += ";position:relative;margin:25px;border:3px solid transparent"; + if (positioned) + document.body.style.cssText += + ";position:relative;margin:25px;border:3px solid transparent"; const target = document.createElement("button"); target.id = "target"; target.style.cssText = "position:absolute;left:40px;top:350px;width:100px;height:40px"; @@ -22,7 +26,12 @@ for (const positionedBody of [false, true]) { (window as any).sketch = circle(target, { seed: 1 }); }, positionedBody); await expect(page.locator(".stet-overlay")).toBeVisible(); - await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); const result = await page.evaluate(() => { const target = document.querySelector("#target")!; const overlay = document.querySelector(".stet-overlay")!; @@ -53,24 +62,39 @@ for (const positionedBody of [false, true]) { }); } -test("nested and sticky targets track scrolling and cancel pending work on destroy", async ({ page }) => { +test("nested and sticky targets track scrolling and cancel pending work on destroy", async ({ + page, +}) => { await page.evaluate(async () => { - document.body.innerHTML = '
'; + document.body.innerHTML = + '
'; const { circle } = await import("/dist/index.js"); - (window as any).sketches = ["target", "sticky"].map(id => circle(document.getElementById(id)!, { seed: 1 })); + (window as any).sketches = ["target", "sticky"].map((id) => + circle(document.getElementById(id)!, { seed: 1 }), + ); }); - await page.locator("#scroller").evaluate(node => { node.scrollTop = 60; }); - await expect.poll(() => page.evaluate(() => { - const target = document.querySelector("#target")!.getBoundingClientRect(); - const overlay = document.querySelector(".stet-overlay")!.getBoundingClientRect(); - return overlay.top - target.top; - })).toBe(-5); + await page.locator("#scroller").evaluate((node) => { + node.scrollTop = 60; + }); + await expect + .poll(() => + page.evaluate(() => { + const target = document.querySelector("#target")!.getBoundingClientRect(); + const overlay = document.querySelector(".stet-overlay")!.getBoundingClientRect(); + return overlay.top - target.top; + }), + ) + .toBe(-5); await page.evaluate(() => window.scrollTo(0, 350)); - await expect.poll(() => page.evaluate(() => { - const target = document.querySelector("#sticky")!.getBoundingClientRect(); - const overlay = document.querySelectorAll(".stet-overlay")[1].getBoundingClientRect(); - return overlay.top - target.top; - })).toBe(-5); + await expect + .poll(() => + page.evaluate(() => { + const target = document.querySelector("#sticky")!.getBoundingClientRect(); + const overlay = document.querySelectorAll(".stet-overlay")[1].getBoundingClientRect(); + return overlay.top - target.top; + }), + ) + .toBe(-5); await page.evaluate(() => { window.dispatchEvent(new Event("scroll")); (window as any).sketches.forEach((handle: any) => handle.destroy()); @@ -81,16 +105,25 @@ test("nested and sticky targets track scrolling and cancel pending work on destr test("fixed targets coalesce a scroll burst without rebuilding SVGs", async ({ page }) => { await page.evaluate(async () => { - document.body.innerHTML = ''; + document.body.innerHTML = + ''; const { circle } = await import("/dist/index.js"); (window as any).sketch = circle(document.querySelector("#target")!, { seed: 1 }); }); - await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); const result = await page.evaluate(async () => { const target = document.querySelector("#target")!; const measure = target.getBoundingClientRect.bind(target); let reads = 0; - target.getBoundingClientRect = () => { reads++; return measure(); }; + target.getBoundingClientRect = () => { + reads++; + return measure(); + }; const overlay = document.querySelector(".stet-overlay")!; const mutations = new MutationObserver(() => {}); mutations.observe(overlay, { subtree: true, attributes: true, childList: true }); @@ -103,3 +136,83 @@ test("fixed targets coalesce a scroll burst without rebuilding SVGs", async ({ p }); expect(result).toEqual({ synchronousReads: 0, reads: 1, changes: 0 }); }); + +const described = (page: import("@playwright/test").Page) => + page.evaluate(() => document.querySelector("#target")!.getAttribute("aria-describedby") ?? ""); + +test("explicit hide survives scroll, resize and observer refresh until show", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.evaluate(async () => { + const target = document.createElement("button"); + target.id = "target"; + target.style.cssText = "position:absolute;left:40px;top:350px;width:100px;height:40px"; + document.body.append(target); + const { circle } = await import("/dist/index.js"); + (window as any).stet = circle(target, { seed: 1, description: "Required" }); + }); + expect(await described(page)).toContain("stet-description-"); + await page.evaluate(() => (window as any).stet.hide()); + await expect + .poll(() => page.evaluate(() => document.querySelector(".stet-overlay")!.hidden)) + .toBe(true); + expect(await described(page)).not.toContain("stet-description-"); + await page.evaluate(() => { + window.scrollTo(0, 200); + window.dispatchEvent(new Event("resize")); + (window as any).stet.refresh(); + (window as any).stet.resketch(9); + }); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve(null))), + ), + ); + await expect + .poll(() => page.evaluate(() => document.querySelector(".stet-overlay")!.hidden)) + .toBe(true); + expect(await described(page)).not.toContain("stet-description-"); + await page.evaluate(() => (window as any).stet.show()); + await expect + .poll(() => page.evaluate(() => document.querySelector(".stet-overlay")!.hidden)) + .toBe(false); + expect(await described(page)).toContain("stet-description-"); + await page.evaluate(() => (window as any).stet.destroy()); +}); + +test("viewport culling preserves descriptions and explicit hide detaches them", async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.evaluate(async () => { + const target = document.createElement("button"); + target.id = "target"; + target.style.cssText = "position:absolute;left:40px;top:1500px;width:100px;height:40px"; + document.body.append(target); + const { circle } = await import("/dist/index.js"); + (window as any).stet = circle(target, { seed: 1, description: "Required" }); + }); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve(null))), + ), + ); + await expect + .poll(() => page.evaluate(() => document.querySelector(".stet-overlay")!.hidden)) + .toBe(true); + expect(await described(page)).toContain("stet-description-"); + await page.evaluate(() => (window as any).stet.hide()); + expect(await described(page)).not.toContain("stet-description-"); + await page.evaluate(() => window.scrollTo(0, 1400)); + await expect + .poll(() => page.evaluate(() => document.querySelector(".stet-overlay")!.hidden)) + .toBe(true); + expect(await described(page)).not.toContain("stet-description-"); + await page.evaluate(() => (window as any).stet.show()); + await expect + .poll(() => page.evaluate(() => document.querySelector(".stet-overlay")!.hidden)) + .toBe(false); + expect(await described(page)).toContain("stet-description-"); + await page.evaluate(() => (window as any).stet.destroy()); +}); diff --git a/tests/stet.test.ts b/tests/stet.test.ts index d2c05a5..cb5a89a 100644 --- a/tests/stet.test.ts +++ b/tests/stet.test.ts @@ -8,7 +8,10 @@ import { sticky, underline, type StetHandle, + type StetOptions, } from "../src/index.js"; +import * as stet from "../src/index.js"; +import { resolveAnimation, updateHandle } from "../src/mount.js"; class ResizeObserverMock { static instances: ResizeObserverMock[] = []; @@ -54,6 +57,7 @@ beforeEach(() => { afterEach(() => { document.body.textContent = ""; + vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -245,16 +249,735 @@ describe("attachers", () => { }); }); +describe("animation options", () => { + it("keeps advertised defaults static", () => { + const caps = JSON.parse(readFileSync("agent/capabilities.json", "utf8")); + expect(resolveAnimation(caps.primitives.circle.defaults).enabled).toBe(false); + expect(resolveAnimation(caps.primitives.underline.defaults).enabled).toBe(false); + }); + + it.each([ + [{}, { enabled: false, duration: 600, delay: 0 }], + [{ animate: false }, { enabled: false, duration: 600, delay: 0 }], + [{ animate: true }, { enabled: true, duration: 600, delay: 0 }], + [{ animationDuration: 250 }, { enabled: true, duration: 250, delay: 0 }], + [{ animationDelay: 40 }, { enabled: true, duration: 600, delay: 40 }], + [ + { animate: false, animationDuration: 250, animationDelay: 40 }, + { enabled: false, duration: 250, delay: 40 }, + ], + ])("resolves %j", (options, expected) => { + expect(resolveAnimation(options)).toEqual(expected); + }); + + it.each(["animationDuration", "animationDelay"] as const)( + "rejects invalid %s before DOM or ARIA mutation", + (name) => { + for (const attach of [circle, underline, highlight]) { + for (const value of [-1, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + const node = element(); + node.setAttribute("aria-describedby", "native"); + expect(() => attach(node, { animate: false, visible: false, [name]: value })).toThrow( + RangeError, + ); + expect(document.querySelector(".stet-overlay")).toBeNull(); + expect(node.getAttribute("aria-describedby")).toBe("native"); + node.remove(); + } + } + }, + ); + + const unsupported = [ + ["highlight", (node: Element, options: StetOptions) => highlight(node, options)], + ["mark", (node: Element, options: StetOptions) => mark(node, "right", options)], + [ + "arrow", + (node: Element, options: StetOptions) => + arrow(element(300, 100), node, { label: "To", ...options }), + ], + ["sticky", (node: Element, options: StetOptions) => sticky(node, { text: "Note", ...options })], + ] as const; + + it.each(unsupported)("rejects enabled animation on %s before mutation", (_, attach) => { + for (const options of [ + { animate: true }, + { animationDelay: 1 }, + { animationDuration: 1, visible: false }, + ]) { + const node = element(); + node.setAttribute("aria-describedby", "native"); + expect(() => attach(node, options)).toThrow(TypeError); + expect(document.querySelector(".stet-overlay")).toBeNull(); + expect(node.getAttribute("aria-describedby")).toBe("native"); + document.body.replaceChildren(); + } + }); + + it.each(unsupported)("allows animate:false timing on static %s", (_, attach) => { + const handle = attach(element(), { animate: false, animationDuration: 10, animationDelay: 5 }); + expect(document.querySelector(".stet-overlay")?.hidden).toBe(false); + handle.destroy(); + }); + + it("accepts enabled animation options for circle and underline", () => { + for (const attach of [circle, underline]) { + const handle = attach(element(), { animate: true, animationDuration: 0, animationDelay: 0 }); + handle.destroy(); + } + }); +}); + +// CORE-04: circle rides the exact same path-length mechanism as underline, so +// every parity case runs for both against identical expectations. +describe.each(["circle", "underline"] as const)("%s reveal parity", (primitive) => { + const attach = primitive === "circle" ? circle : underline; + const path = () => document.querySelector(`.stet-overlay--${primitive} path`)!; + const offset = () => path().getAttribute("stroke-dashoffset"); + const overlay = () => document.querySelector(`.stet-overlay--${primitive}`)!; + + it("draws on with path-length dashes, never opacity, and settles final", () => { + vi.useFakeTimers(); + const handle = attach(element(), { seed: 1, animate: true, animationDuration: 400 }); + // Frame zero is fully hidden but already carries its final geometry: no flash. + expect(path().getAttribute("pathLength")).toBe("1"); + expect(path().getAttribute("stroke-dasharray")).toBe("1"); + expect(offset()).toBe("1"); + expect(path().getAttribute("d")).toBeTruthy(); + expect(path().hasAttribute("opacity")).toBe(false); + vi.advanceTimersByTime(200); + const mid = Number(offset()); + expect(mid).toBeGreaterThan(0); + expect(mid).toBeLessThan(1); + expect(path().hasAttribute("opacity")).toBe(false); + vi.advanceTimersByTime(400); + // Settled frame drops every reveal attribute and keeps the static path. + expect(path().hasAttribute("pathLength")).toBe(false); + expect(path().hasAttribute("stroke-dasharray")).toBe(false); + expect(offset()).toBeNull(); + expect(path().hasAttribute("opacity")).toBe(false); + handle.destroy(); + }); + + it("keeps settled geometry identical to the static annotation", () => { + vi.useFakeTimers(); + const staticHandle = attach(element(), { seed: 11 }); + const staticPath = path(); + expect(staticPath.hasAttribute("pathLength")).toBe(false); + const settledD = staticPath.getAttribute("d"); + expect(settledD).toBeTruthy(); + staticHandle.destroy(); + document.body.replaceChildren(); + + const handle = attach(element(), { seed: 11, animate: true, animationDuration: 200 }); + vi.advanceTimersByTime(400); + expect(offset()).toBeNull(); + expect(path().hasAttribute("pathLength")).toBe(false); + expect(path().getAttribute("d")).toBe(settledD); + handle.destroy(); + }); + + it("redraws at current progress without replay on refresh, resketch, scroll and resize", () => { + vi.useFakeTimers(); + const node = element(); + const handle = attach(node, { seed: 1, animate: true, animationDuration: 600 }); + vi.advanceTimersByTime(300); + const before = Number(offset()); + expect(before).toBeGreaterThan(0); + expect(before).toBeLessThan(1); + window.dispatchEvent(new Event("scroll")); + window.dispatchEvent(new Event("resize")); + handle.refresh(); + handle.resketch(9); + const after = Number(offset()); + // A replay would jump back toward 1; progress can only advance. + expect(after).toBeLessThanOrEqual(before); + vi.advanceTimersByTime(320); + expect(offset()).toBeNull(); + handle.destroy(); + }); + + it("defers an initial visible:false reveal until show and never runs it early", async () => { + vi.useFakeTimers(); + const handle = attach(element(), { + seed: 1, + visible: false, + animate: true, + animationDuration: 200, + }); + expect(overlay().hidden).toBe(true); + expect(offset()).toBeNull(); + vi.advanceTimersByTime(500); + expect(overlay().hidden).toBe(true); + expect(offset()).toBeNull(); + // show() joins the new operation, so its promise settles on completion. + const reveal = handle.show(); + expect(overlay().hidden).toBe(false); + expect(offset()).toBe("1"); + vi.advanceTimersByTime(300); + expect(offset()).toBeNull(); + await expect(reveal).resolves.toEqual({ status: "finished" }); + handle.destroy(); + }); + + it("completes while offscreen and shows the final frame once unculled", () => { + vi.useFakeTimers(); + const node = element(20, 1400); + const handle = attach(node, { seed: 1, animate: true, animationDuration: 200 }); + expect(overlay().hidden).toBe(true); + vi.advanceTimersByTime(500); + expect(offset()).toBeNull(); + const onscreen = new DOMRect(10, 20, 100, 30); + vi.mocked(node.getBoundingClientRect).mockReturnValue(onscreen); + vi.mocked(node.getClientRects).mockReturnValue([onscreen] as unknown as DOMRectList); + handle.refresh(); + expect(overlay().hidden).toBe(false); + expect(offset()).toBeNull(); + handle.destroy(); + }); + + it("completes while detached and reconnects at the final frame", () => { + vi.useFakeTimers(); + const node = element(); + const handle = attach(node, { seed: 1, animate: true, animationDuration: 200 }); + node.remove(); + handle.refresh(); + expect(overlay().hidden).toBe(true); + vi.advanceTimersByTime(500); + expect(offset()).toBeNull(); + document.body.prepend(node); + handle.refresh(); + expect(overlay().hidden).toBe(false); + expect(offset()).toBeNull(); + handle.destroy(); + }); + + it("settles immediately under reduced motion and for zero duration", () => { + vi.useFakeTimers(); + vi.stubGlobal("matchMedia", () => ({ matches: true })); + const reduced = attach(element(), { seed: 1, animate: true, animationDuration: 200 }); + expect(path().hasAttribute("stroke-dashoffset")).toBe(false); + expect(path().hasAttribute("pathLength")).toBe(false); + reduced.destroy(); + document.body.replaceChildren(); + + vi.stubGlobal("matchMedia", () => ({ matches: false })); + const zero = attach(element(), { + seed: 1, + animate: true, + animationDuration: 0, + animationDelay: 500, + }); + expect(path().hasAttribute("stroke-dashoffset")).toBe(false); + zero.destroy(); + }); + + it("leaves a static annotation free of reveal attributes across refresh", () => { + const handle = attach(element(), { seed: 1 }); + expect(path().hasAttribute("pathLength")).toBe(false); + expect(path().hasAttribute("stroke-dasharray")).toBe(false); + expect(path().hasAttribute("stroke-dashoffset")).toBe(false); + handle.refresh(); + expect(offset()).toBeNull(); + handle.destroy(); + }); +}); + +// CORE-05: one async operation per handle. Every row of the runtime contract +// transition table runs for both stroked primitives against identical behavior. +describe.each(["circle", "underline"] as const)("%s operation state machine", (primitive) => { + const attach = primitive === "circle" ? circle : underline; + const overlay = () => document.querySelector(`.stet-overlay--${primitive}`)!; + const path = () => document.querySelector(`.stet-overlay--${primitive} path`)!; + const offset = () => path().getAttribute("stroke-dashoffset"); + const reducedMotion = (initial: boolean) => { + let matches = initial; + const listeners = new Set<() => void>(); + return { + get matches() { + return matches; + }, + addEventListener: (_type: string, listener: () => void) => { + listeners.add(listener); + }, + removeEventListener: (_type: string, listener: () => void) => { + listeners.delete(listener); + }, + set(value: boolean) { + matches = value; + for (const listener of listeners) listener(); + }, + }; + }; + + it("joins the attach operation exactly and returns a distinct finished show when settled", async () => { + vi.useFakeTimers(); + const handle = attach(element(), { seed: 1, animate: true, animationDuration: 200 }); + expect(overlay().hidden).toBe(false); + expect(offset()).toBe("1"); + const joined = handle.show(); + expect(handle.show()).toBe(joined); + handle.refresh(); + handle.resketch(2); + expect(handle.show()).toBe(joined); + vi.advanceTimersByTime(250); + await expect(joined).resolves.toEqual({ status: "finished" }); + // Settled-visible show is a new fulfilled result and must not redraw. + const settledPath = path(); + const settled = handle.show(); + expect(settled).not.toBe(joined); + await expect(settled).resolves.toEqual({ status: "finished" }); + expect(path()).toBe(settledPath); + handle.destroy(); + }); + + it("returns the same promise while delaying and reveals at the deadline", async () => { + vi.useFakeTimers(); + const handle = attach(element(), { + seed: 1, + animate: true, + animationDelay: 100, + animationDuration: 200, + }); + expect(offset()).toBe("1"); + const joined = handle.show(); + expect(handle.show()).toBe(joined); + vi.advanceTimersByTime(120); + const mid = Number(offset()); + expect(mid).toBeGreaterThan(0); + expect(mid).toBeLessThan(1); + vi.advanceTimersByTime(250); + await expect(joined).resolves.toEqual({ status: "finished" }); + handle.destroy(); + }); + + it("starts an operation on show after hidden attach and restores ARIA", async () => { + vi.useFakeTimers(); + const node = element(); + const handle = attach(node, { + seed: 1, + visible: false, + animate: true, + animationDuration: 200, + description: "Required", + }); + expect(overlay().hidden).toBe(true); + expect(node.hasAttribute("aria-describedby")).toBe(false); + const reveal = handle.show(); + expect(overlay().hidden).toBe(false); + expect(offset()).toBe("1"); + expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); + expect(handle.show()).toBe(reveal); + vi.advanceTimersByTime(250); + await expect(reveal).resolves.toEqual({ status: "finished" }); + handle.destroy(); + }); + + it("starts a fresh operation on show after explicit hide", async () => { + vi.useFakeTimers(); + const node = element(); + const handle = attach(node, { + seed: 1, + animate: true, + animationDuration: 200, + description: "Required", + }); + vi.advanceTimersByTime(250); + handle.hide(); + expect(overlay().hidden).toBe(true); + expect(node.hasAttribute("aria-describedby")).toBe(false); + const reveal = handle.show(); + expect(overlay().hidden).toBe(false); + expect(offset()).toBe("1"); + expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); + vi.advanceTimersByTime(250); + await expect(reveal).resolves.toEqual({ status: "finished" }); + handle.destroy(); + }); + + it("returns a new finished promise for settled-visible static show without redrawing", async () => { + const handle = attach(element(), { seed: 1 }); + const settledPath = path(); + const first = handle.show(); + const second = handle.show(); + expect(first).not.toBe(second); + await expect(first).resolves.toEqual({ status: "finished" }); + await expect(second).resolves.toEqual({ status: "finished" }); + expect(path()).toBe(settledPath); + handle.destroy(); + }); + + it("replays a settled operation with the same seed and a distinct promise", async () => { + vi.useFakeTimers(); + const handle = attach(element(), { seed: 1, animate: true, animationDuration: 200 }); + vi.advanceTimersByTime(250); + const seeded = path().getAttribute("d"); + const replay = handle.replay(); + expect(overlay().hidden).toBe(false); + expect(offset()).toBe("1"); + vi.advanceTimersByTime(250); + await expect(replay).resolves.toEqual({ status: "finished" }); + expect(path().getAttribute("d")).toBe(seeded); + handle.destroy(); + }); + + it("replays a static handle into a new finished promise at the same seed", async () => { + const handle = attach(element(), { seed: 1 }); + const seeded = path().getAttribute("d"); + const first = handle.replay(); + const second = handle.replay(); + expect(first).not.toBe(second); + await expect(first).resolves.toEqual({ status: "finished" }); + await expect(second).resolves.toEqual({ status: "finished" }); + expect(path().getAttribute("d")).toBe(seeded); + handle.destroy(); + }); + + it("replays from explicit hide by restoring visibility and ARIA", async () => { + vi.useFakeTimers(); + const node = element(); + const handle = attach(node, { + seed: 1, + animate: true, + animationDuration: 200, + description: "Required", + }); + vi.advanceTimersByTime(250); + handle.hide(); + expect(node.hasAttribute("aria-describedby")).toBe(false); + const replay = handle.replay(); + expect(overlay().hidden).toBe(false); + expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); + vi.advanceTimersByTime(250); + await expect(replay).resolves.toEqual({ status: "finished" }); + handle.destroy(); + }); + + it("supersedes an in-flight operation on replay, cancelling it before the replacement settles", async () => { + vi.useFakeTimers(); + const handle = attach(element(), { + seed: 1, + animate: true, + animationDelay: 200, + animationDuration: 400, + }); + vi.advanceTimersByTime(100); + const old = handle.show(); + const order: string[] = []; + old.then(() => order.push("old")); + const next = handle.replay(); + expect(next).not.toBe(old); + next.then(() => order.push("new")); + // A following show joins the replacement, not the cancelled operation. + expect(handle.show()).toBe(next); + await vi.advanceTimersByTimeAsync(1000); + await expect(old).resolves.toEqual({ status: "cancelled" }); + await expect(next).resolves.toEqual({ status: "finished" }); + expect(order).toEqual(["old", "new"]); + handle.destroy(); + }); + + it("cancels exactly once on hide and never settles finished late", async () => { + vi.useFakeTimers(); + const handle = attach(element(), { seed: 1, animate: true, animationDuration: 400 }); + const op = handle.show(); + const statuses: string[] = []; + op.then((result) => statuses.push(result.status)); + handle.hide(); + handle.hide(); + await expect(op).resolves.toEqual({ status: "cancelled" }); + await vi.advanceTimersByTimeAsync(2000); + handle.refresh(); + handle.resketch(5); + await Promise.resolve(); + expect(statuses).toEqual(["cancelled"]); + expect(offset()).toBeNull(); + handle.destroy(); + }); + + it("cancels on destroy and returns fresh cancelled promises after teardown", async () => { + vi.useFakeTimers(); + const node = element(); + const handle = attach(node, { + seed: 1, + animate: true, + animationDuration: 400, + description: "Required", + }); + const op = handle.show(); + handle.destroy(); + await expect(op).resolves.toEqual({ status: "cancelled" }); + const show = handle.show(); + const replay = handle.replay(); + expect(show).not.toBe(replay); + await expect(show).resolves.toEqual({ status: "cancelled" }); + await expect(replay).resolves.toEqual({ status: "cancelled" }); + // Void calls are harmless no-ops after teardown. + handle.destroy(); + handle.hide(); + handle.refresh(); + handle.resketch(3); + expect(overlay()).toBeNull(); + expect(node.hasAttribute("aria-describedby")).toBe(false); + await vi.advanceTimersByTimeAsync(1000); + }); + + it("creates no operation for zero duration or reduced motion", async () => { + vi.useFakeTimers(); + vi.stubGlobal("matchMedia", () => ({ matches: true })); + const reduced = attach(element(), { seed: 1, animate: true, animationDuration: 200 }); + expect(offset()).toBeNull(); + const first = reduced.show(); + const second = reduced.show(); + expect(first).not.toBe(second); + await expect(first).resolves.toEqual({ status: "finished" }); + reduced.destroy(); + document.body.replaceChildren(); + + vi.stubGlobal("matchMedia", () => ({ matches: false })); + const zero = attach(element(), { + seed: 1, + animate: true, + animationDuration: 0, + animationDelay: 500, + }); + expect(offset()).toBeNull(); + zero.hide(); + await expect(zero.show()).resolves.toEqual({ status: "finished" }); + expect(overlay().hidden).toBe(false); + zero.destroy(); + }); + + it("keeps an initially hidden handle settled even under zero duration", () => { + vi.useFakeTimers(); + const handle = attach(element(), { + seed: 1, + visible: false, + animate: true, + animationDuration: 0, + }); + expect(overlay().hidden).toBe(true); + expect(offset()).toBeNull(); + handle.destroy(); + }); + + it("settles an active operation finished when reduced motion becomes active", async () => { + vi.useFakeTimers(); + const media = reducedMotion(false); + vi.stubGlobal("matchMedia", () => media); + const handle = attach(element(), { seed: 1, animate: true, animationDuration: 400 }); + const op = handle.show(); + vi.advanceTimersByTime(100); + expect(offset()).not.toBeNull(); + media.set(true); + expect(offset()).toBeNull(); + await expect(op).resolves.toEqual({ status: "finished" }); + handle.destroy(); + }); +}); + +describe("visibility and ARIA ownership", () => { + const overlay = () => document.querySelector(".stet-overlay"); + + it("defaults to visible with descriptions attached and a settled show", async () => { + const node = element(); + const handle = circle(node, { seed: 7, description: "Required" }); + const path = document.querySelector(".stet-svg path"); + expect(overlay()?.hidden).toBe(false); + expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); + // Settled-visible show is a distinct fulfilled result that must not redraw. + const firstShow = handle.show(); + const secondShow = handle.show(); + expect(firstShow).not.toBe(secondShow); + await expect(firstShow).resolves.toEqual({ status: "finished" }); + expect(document.querySelector(".stet-svg path")).toBe(path); + expect(overlay()?.hidden).toBe(false); + handle.destroy(); + }); + + it.each([ + ["circle", (node: Element) => circle(node, { description: "Required", visible: false })], + ["underline", (node: Element) => underline(node, { description: "Required", visible: false })], + ["highlight", (node: Element) => highlight(node, { description: "Required", visible: false })], + ["mark", (node: Element) => mark(node, "right", { description: "Required", visible: false })], + ["sticky", (node: Element) => sticky(node, { text: "Required", visible: false })], + ])("initial visible:false hides %s and detaches its own ARIA", (_, attach) => { + const node = element(); + node.setAttribute("aria-describedby", "native"); + const handle = attach(node); + expect(overlay()?.hidden).toBe(true); + expect(node.getAttribute("aria-describedby")).toBe("native"); + handle.destroy(); + }); + + it("initial visible:false hides arrow labels and detaches the destination description", () => { + const to = element(300, 100); + to.setAttribute("aria-describedby", "native"); + const handle = arrow(element(), to, { label: "Destination", visible: false }); + expect(overlay()?.hidden).toBe(true); + expect(to.getAttribute("aria-describedby")).toBe("native"); + handle.destroy(); + }); + + it("explicit hide survives refresh, resize, scroll and resketch until show", async () => { + const node = element(); + const handle = circle(node, { seed: 1, description: "Required" }); + handle.hide(); + expect(overlay()?.hidden).toBe(true); + expect(node.hasAttribute("aria-describedby")).toBe(false); + handle.refresh(); + window.dispatchEvent(new Event("resize")); + window.dispatchEvent(new Event("scroll")); + handle.resketch(2); + expect(overlay()?.hidden).toBe(true); + expect(node.hasAttribute("aria-describedby")).toBe(false); + await expect(handle.show()).resolves.toEqual({ status: "finished" }); + expect(overlay()?.hidden).toBe(false); + expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); + handle.hide(); + expect(node.hasAttribute("aria-describedby")).toBe(false); + handle.destroy(); + }); + + it("show restores each owned description once and preserves foreign IDs", async () => { + const node = element(); + node.setAttribute("aria-describedby", "native"); + const handle = circle(node, { description: "Required", visible: false }); + expect(node.getAttribute("aria-describedby")).toBe("native"); + await handle.show(); + const owned = node.getAttribute("aria-describedby")!; + expect(owned.startsWith("native ")).toBe(true); + expect(owned.match(/stet-description-/g)).toHaveLength(1); + await handle.show(); + expect(node.getAttribute("aria-describedby")).toBe(owned); + handle.destroy(); + expect(node.getAttribute("aria-describedby")).toBe("native"); + }); + + it("removes only owned IDs when the application changes ARIA while hidden", async () => { + const node = element(); + const handle = circle(node, { description: "Required" }); + handle.hide(); + node.setAttribute("aria-describedby", "later"); + await handle.show(); + const after = node.getAttribute("aria-describedby")!; + expect(after.match(/stet-description-/g)).toHaveLength(1); + expect(after.split(/\s+/)).toContain("later"); + handle.destroy(); + expect(node.getAttribute("aria-describedby")).toBe("later"); + }); + + it("viewport culling hides the overlay but preserves descriptions", () => { + const node = element(); + const handle = sticky(node, { text: "Note", seed: 1 }); + expect(overlay()?.hidden).toBe(false); + const offscreen = new DOMRect(10, 2000, 100, 30); + vi.mocked(node.getBoundingClientRect).mockReturnValue(offscreen); + vi.mocked(node.getClientRects).mockReturnValue([offscreen] as unknown as DOMRectList); + handle.refresh(); + expect(overlay()?.hidden).toBe(true); + expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); + const onscreen = new DOMRect(10, 20, 100, 30); + vi.mocked(node.getBoundingClientRect).mockReturnValue(onscreen); + vi.mocked(node.getClientRects).mockReturnValue([onscreen] as unknown as DOMRectList); + handle.refresh(); + expect(overlay()?.hidden).toBe(false); + expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); + handle.destroy(); + }); + + it("owns mount, arrow label and sticky text ARIA through hide/show/destroy", async () => { + const node = element(); + const to = element(300, 100); + node.setAttribute("aria-describedby", "sticky-native"); + to.setAttribute("aria-describedby", "arrow-native"); + const arrowHandle = arrow(element(600, 200), to, { + label: "Destination", + seed: 1, + visible: false, + }); + const stickyHandle = sticky(node, { text: "Note", seed: 1, visible: false }); + expect(to.getAttribute("aria-describedby")).toBe("arrow-native"); + expect(node.getAttribute("aria-describedby")).toBe("sticky-native"); + await expect(arrowHandle.show()).resolves.toEqual({ status: "finished" }); + await expect(stickyHandle.show()).resolves.toEqual({ status: "finished" }); + to.setAttribute("aria-describedby", `${to.getAttribute("aria-describedby")} arrow-later`); + node.setAttribute("aria-describedby", `${node.getAttribute("aria-describedby")} sticky-later`); + expect(to.getAttribute("aria-describedby")).toContain("stet-description-"); + expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); + arrowHandle.hide(); + stickyHandle.hide(); + expect(to.getAttribute("aria-describedby")).toBe("arrow-native arrow-later"); + expect(node.getAttribute("aria-describedby")).toBe("sticky-native sticky-later"); + await expect(arrowHandle.replay()).resolves.toEqual({ status: "finished" }); + await expect(stickyHandle.replay()).resolves.toEqual({ status: "finished" }); + expect(to.getAttribute("aria-describedby")?.match(/stet-description-/g)).toHaveLength(1); + expect(node.getAttribute("aria-describedby")?.match(/stet-description-/g)).toHaveLength(1); + arrowHandle.destroy(); + stickyHandle.destroy(); + expect(to.getAttribute("aria-describedby")).toBe("arrow-native arrow-later"); + expect(node.getAttribute("aria-describedby")).toBe("sticky-native sticky-later"); + }); + + it("returns the complete handle from arrow and sticky", () => { + const circleHandle = circle(element(), { seed: 1 }); + const expected = Object.keys(circleHandle).sort(); + const handles: StetHandle[] = [ + arrow(element(), element(300, 100), { label: "Destination", seed: 1 }), + sticky(element(), { text: "Note", seed: 1 }), + ]; + for (const candidate of handles) { + expect(Object.keys(candidate).sort()).toEqual(expected); + for (const method of expected) { + expect(typeof (candidate as unknown as Record)[method]).toBe("function"); + } + candidate.destroy(); + } + circleHandle.destroy(); + }); + + it("no-ops after destroy and returns a cancelled show", async () => { + const node = element(); + const handle = circle(node, { seed: 1, description: "Required" }); + handle.destroy(); + handle.destroy(); + handle.hide(); + handle.refresh(); + handle.resketch(3); + await expect(handle.show()).resolves.toEqual({ status: "cancelled" }); + expect(overlay()).toBeNull(); + expect(node.hasAttribute("aria-describedby")).toBe(false); + }); +}); + // Compare advertised defaults to observable runtime output, not renderer source text. it("agent capability defaults reproduce omitted runtime defaults", () => { const caps = JSON.parse(readFileSync("agent/capabilities.json", "utf8")); - const from = element(), to = element(320, 100); + const from = element(), + to = element(320, 100); for (const [name, meta] of Object.entries(caps.primitives)) { - const required = name === "sticky" ? { text: "Review first." } : name === "arrow" ? { label: "Destination" } : {}; - const defaults = Object.fromEntries(Object.entries(meta.defaults).filter(([,v]) => typeof v === "number" || typeof v === "boolean" || v === "auto")); - const attach = (options: any) => name === "mark" ? mark(from, "wrong", options) : name === "arrow" ? arrow(from, to, options) : ({ circle, underline, highlight, sticky } as any)[name](from, options); + const required = + name === "sticky" + ? { text: "Review first." } + : name === "arrow" + ? { label: "Destination" } + : {}; + const defaults = Object.fromEntries( + Object.entries(meta.defaults).filter( + ([, v]) => typeof v === "number" || typeof v === "boolean" || v === "auto", + ), + ); + const attach = (options: any) => + name === "mark" + ? mark(from, "wrong", options) + : name === "arrow" + ? arrow(from, to, options) + : ({ circle, underline, highlight, sticky } as any)[name](from, options); const implicit = attach({ ...required, seed: 42 }); - const snapshot = () => ({ paths: paths(), position: document.querySelector(".stet-overlay")!.style.cssText }); + const snapshot = () => ({ + paths: paths(), + position: document.querySelector(".stet-overlay")!.style.cssText, + }); const expected = snapshot(); from.dispatchEvent(new Event("pointerenter")); expect(snapshot()).toEqual(expected); @@ -265,31 +988,641 @@ it("agent capability defaults reproduce omitted runtime defaults", () => { } }); -it('nudges notes after side selection and clamps them to the viewport', () => { +it("nudges notes after side selection and clamps them to the viewport", () => { const node = element(300, 300, 100, 30); - let handle = sticky(node, { text: 'Evidence', side: 'right', seed: 2 }); - const read = () => { const style = document.querySelector('.stet-overlay--sticky')!.style; return [parseFloat(style.left), parseFloat(style.top)]; }; - const baseline = read(); handle.destroy(); - handle = sticky(node, { text: 'Evidence', side: 'right', seed: 2, offsetX: 25, offsetY: -20 }); + let handle = sticky(node, { text: "Evidence", side: "right", seed: 2 }); + const read = () => { + const style = document.querySelector(".stet-overlay--sticky")!.style; + return [parseFloat(style.left), parseFloat(style.top)]; + }; + const baseline = read(); + handle.destroy(); + handle = sticky(node, { text: "Evidence", side: "right", seed: 2, offsetX: 25, offsetY: -20 }); + expect(read()).toEqual([baseline[0] + 25, baseline[1] - 20]); + handle.refresh(); expect(read()).toEqual([baseline[0] + 25, baseline[1] - 20]); - handle.refresh(); expect(read()).toEqual([baseline[0] + 25, baseline[1] - 20]); handle.destroy(); - handle = sticky(node, { text: 'Evidence', offsetX: -10000, offsetY: -10000 }); - expect(read()).toEqual([12, 12]); handle.destroy(); + handle = sticky(node, { text: "Evidence", offsetX: -10000, offsetY: -10000 }); + expect(read()).toEqual([12, 12]); + handle.destroy(); }); -it('moves an arrow label independently of its path and retains viewport clamping', () => { - const from = element(300, 300), to = element(600, 400); - const read = () => { const style = document.querySelector('.stet-label')!.style; return [parseFloat(style.left), parseFloat(style.top)]; }; - let handle = arrow(from, to, { label: 'Review', seed: 2 }); - const baseline = read(), path = paths(); handle.destroy(); - handle = arrow(from, to, { label: 'Review', seed: 2, labelOffsetX: -25, labelOffsetY: 20 }); - expect(read()).toEqual([baseline[0] - 25, baseline[1] + 20]); expect(paths()).toEqual(path); - handle.refresh(); expect(read()).toEqual([baseline[0] - 25, baseline[1] + 20]); handle.destroy(); - handle = arrow(from, to, { label: 'Review', labelOffsetX: -10000, labelOffsetY: -10000 }); - const label = document.querySelector('.stet-label')!; +it("moves an arrow label independently of its path and retains viewport clamping", () => { + const from = element(300, 300), + to = element(600, 400); + const read = () => { + const style = document.querySelector(".stet-label")!.style; + return [parseFloat(style.left), parseFloat(style.top)]; + }; + let handle = arrow(from, to, { label: "Review", seed: 2 }); + const baseline = read(), + path = paths(); + handle.destroy(); + handle = arrow(from, to, { label: "Review", seed: 2, labelOffsetX: -25, labelOffsetY: 20 }); + expect(read()).toEqual([baseline[0] - 25, baseline[1] + 20]); + expect(paths()).toEqual(path); + handle.refresh(); + expect(read()).toEqual([baseline[0] - 25, baseline[1] + 20]); + handle.destroy(); + handle = arrow(from, to, { label: "Review", labelOffsetX: -10000, labelOffsetY: -10000 }); + const label = document.querySelector(".stet-label")!; const root = label.parentElement!; expect(parseFloat(root.style.left) + parseFloat(label.style.left)).toBe(8); expect(parseFloat(root.style.top) + parseFloat(label.style.top)).toBe(8); handle.destroy(); }); + +// CORE-06: reveal composes with ambient boil, hover resketch, and live media +// changes without replaying or changing an in-flight operation's deadline. +describe.each(["circle", "underline"] as const)("%s ambient motion composition", (primitive) => { + const attach = primitive === "circle" ? circle : underline; + const overlay = () => document.querySelector(`.stet-overlay--${primitive}`)!; + const drawn = () => [ + ...document.querySelectorAll(`.stet-overlay--${primitive} path`), + ]; + const first = () => drawn()[0]; + const offset = () => first().getAttribute("stroke-dashoffset"); + const reducedMotion = (initial: boolean) => { + let matches = initial; + const listeners = new Set<() => void>(); + return { + get matches() { + return matches; + }, + addEventListener: (_type: string, listener: () => void) => { + listeners.add(listener); + }, + removeEventListener: (_type: string, listener: () => void) => { + listeners.delete(listener); + }, + set(value: boolean) { + matches = value; + for (const listener of listeners) listener(); + }, + }; + }; + + it("settles finished at the final frame when reduced motion activates during the delay", async () => { + vi.useFakeTimers(); + const media = reducedMotion(false); + vi.stubGlobal("matchMedia", () => media); + const handle = attach(element(), { + seed: 1, + animate: true, + animationDelay: 200, + animationDuration: 400, + }); + const op = handle.show(); + expect(offset()).toBe("1"); + media.set(true); + // The delay is skipped entirely and the final static frame has no reveal state. + expect(offset()).toBeNull(); + expect(first().hasAttribute("pathLength")).toBe(false); + expect(overlay().hidden).toBe(false); + await expect(op).resolves.toEqual({ status: "finished" }); + // The operation is settled, so a later show is a fresh no-replay result. + const settled = handle.show(); + expect(settled).not.toBe(op); + await expect(settled).resolves.toEqual({ status: "finished" }); + handle.destroy(); + }); + + it("settles finished and drops boil when reduced motion activates during the reveal", async () => { + vi.useFakeTimers(); + const media = reducedMotion(false); + vi.stubGlobal("matchMedia", () => media); + const handle = attach(element(), { seed: 1, animate: true, animationDuration: 400, boil: 0.4 }); + expect(drawn()).toHaveLength(3); + const op = handle.show(); + vi.advanceTimersByTime(100); + expect(Number(offset())).toBeLessThan(1); + media.set(true); + expect(offset()).toBeNull(); + // Reduced motion renders one static variant and touches no reveal attributes. + expect(drawn()).toHaveLength(1); + expect(drawn()[0].getAttribute("class")).not.toContain("stet-boil"); + await expect(op).resolves.toEqual({ status: "finished" }); + handle.destroy(); + }); + + it("hover resketch redraws at current progress and keeps the operation deadline", async () => { + vi.useFakeTimers(); + const node = element(); + const handle = attach(node, { + seed: 1, + animate: true, + animationDuration: 600, + boil: 0.3, + resketchOnHover: true, + }); + const op = handle.show(); + vi.advanceTimersByTime(300); + const before = Number(offset()); + const beforeD = first().getAttribute("d"); + expect(before).toBeGreaterThan(0); + expect(before).toBeLessThan(1); + node.dispatchEvent(new PointerEvent("pointerenter")); + // The operation is untouched: same promise, and progress never jumps back. + expect(handle.show()).toBe(op); + expect(Number(offset())).toBeLessThanOrEqual(before); + expect(first().getAttribute("d")).not.toBe(beforeD); + // Only the remaining time is needed to settle, so the deadline is unchanged. + vi.advanceTimersByTime(310); + expect(offset()).toBeNull(); + await expect(op).resolves.toEqual({ status: "finished" }); + handle.destroy(); + }); + + it("keeps every boil variant hidden at frame zero and clear at the final frame", () => { + vi.useFakeTimers(); + const handle = attach(element(), { seed: 1, animate: true, animationDuration: 300, boil: 0.5 }); + const variants = drawn(); + expect(variants).toHaveLength(3); + for (const variant of variants) { + expect(variant.getAttribute("class")).toContain("stet-boil"); + expect(variant.getAttribute("pathLength")).toBe("1"); + expect(variant.getAttribute("stroke-dasharray")).toBe("1"); + expect(variant.getAttribute("stroke-dashoffset")).toBe("1"); + expect(variant.hasAttribute("opacity")).toBe(false); + } + vi.advanceTimersByTime(400); + for (const variant of variants) { + expect(variant.getAttribute("pathLength")).toBeNull(); + expect(variant.getAttribute("stroke-dasharray")).toBeNull(); + expect(variant.getAttribute("stroke-dashoffset")).toBeNull(); + expect(variant.hasAttribute("opacity")).toBe(false); + } + handle.destroy(); + }); + + it("reduced motion yields one stable variant and disables hover resketch", () => { + vi.useFakeTimers(); + const media = reducedMotion(true); + vi.stubGlobal("matchMedia", () => media); + const node = element(); + const handle = attach(node, { seed: 1, boil: 0.5, resketchOnHover: true }); + expect(drawn()).toHaveLength(1); + expect(drawn()[0].getAttribute("class")).not.toContain("stet-boil"); + const stable = paths(); + node.dispatchEvent(new PointerEvent("pointerenter")); + expect(drawn()).toHaveLength(1); + expect(paths()).toEqual(stable); + // A repeat refresh is still the same stable single variant for capture. + handle.refresh(); + expect(paths()).toEqual(stable); + handle.destroy(); + }); + + it("settles an offscreen operation finished when reduced motion activates while culled", async () => { + vi.useFakeTimers(); + const node = element(20, 1400); + const media = reducedMotion(false); + vi.stubGlobal("matchMedia", () => media); + const handle = attach(node, { seed: 1, animate: true, animationDuration: 600 }); + expect(overlay().hidden).toBe(true); + const op = handle.show(); + vi.advanceTimersByTime(200); + media.set(true); + await expect(op).resolves.toEqual({ status: "finished" }); + expect(offset()).toBeNull(); + // Reconnecting shows the final frame rather than replaying. + const onscreen = new DOMRect(10, 20, 100, 30); + vi.mocked(node.getBoundingClientRect).mockReturnValue(onscreen); + vi.mocked(node.getClientRects).mockReturnValue([onscreen] as unknown as DOMRectList); + handle.refresh(); + expect(overlay().hidden).toBe(false); + expect(offset()).toBeNull(); + handle.destroy(); + }); +}); + +// CORE-07: adapters update supported runtime data through the source-only +// nonreplaying path. Every field must apply in place without touching the +// current operation, the current seed, or explicit visibility. +describe("updateHandle", () => { + const names = ["circle", "underline", "highlight", "mark", "sticky", "arrow"] as const; + type Name = (typeof names)[number]; + const overlay = () => document.querySelector(".stet-overlay")!; + const path = () => document.querySelector(".stet-svg path")!; + const offset = () => path().getAttribute("stroke-dashoffset"); + + const baseOptions = (name: Name): Record => + name === "mark" + ? { seed: 1, kind: "right" } + : name === "sticky" + ? { seed: 1, text: "Note" } + : name === "arrow" + ? { seed: 1, label: "Note" } + : { seed: 1 }; + + function attachHandle(name: Name, node: Element, to: Element): StetHandle { + switch (name) { + case "circle": + return circle(node, { seed: 1 }); + case "underline": + return underline(node, { seed: 1 }); + case "highlight": + return highlight(node, { seed: 1 }); + case "mark": + return mark(node, "right", { seed: 1 }); + case "sticky": + return sticky(node, { seed: 1, text: "Note" }); + case "arrow": + return arrow(node, to, { seed: 1, label: "Note" }); + } + } + + it("rejects handles not created by stet", () => { + expect(() => updateHandle({} as StetHandle, { seed: 1 })).toThrow("created by stet"); + }); + + it("no-ops when the snapshot has the same values", () => { + const node = element(10, 20); + const handle = circle(node, { seed: 1, description: "A" }); + const left = overlay().style.left; + const shifted = new DOMRect(500, 500, 100, 30); + vi.mocked(node.getBoundingClientRect).mockReturnValue(shifted); + vi.mocked(node.getClientRects).mockReturnValue([shifted] as unknown as DOMRectList); + updateHandle(handle, { seed: 1, description: "A" }); + // An identical snapshot must not redraw, so the stale geometry stands. + expect(overlay().style.left).toBe(left); + // A changed value does redraw against the new geometry. + updateHandle(handle, { seed: 1, description: "B" }); + expect(overlay().style.left).toBe("495px"); + handle.destroy(); + }); + + it("returns removed values to defaults", () => { + const node = element(); + const handle = circle(node, { + seed: 1, + padding: 20, + description: "A", + stroke: "red", + fill: "blue", + }); + expect(parseFloat(overlay().style.width)).toBe(140); + expect(document.querySelector(".stet-description")).not.toBeNull(); + updateHandle(handle, { seed: 1 }); + expect(parseFloat(overlay().style.width)).toBe(110); + expect(document.querySelector(".stet-description")).toBeNull(); + expect(overlay().style.getPropertyValue("--stet-ink")).toBe(""); + expect(overlay().style.getPropertyValue("--stet-local-fill")).toBe(""); + handle.destroy(); + }); + + it.each(names)("updates stroke, fill, and width for %s", (name) => { + const node = element(); + const to = element(300, 100); + const handle = attachHandle(name, node, to); + updateHandle(handle, { + ...baseOptions(name), + stroke: "rgb(1, 2, 3)", + fill: "rgb(4, 5, 6)", + width: 7, + }); + const style = overlay().style; + expect(style.getPropertyValue("--stet-ink")).toBe("rgb(1, 2, 3)"); + expect(style.getPropertyValue("--stet-local-fill")).toBe("rgb(4, 5, 6)"); + expect(style.getPropertyValue("--stet-width")).toBe("7"); + handle.destroy(); + }); + + it.each(names)("redraws on seed and roughness changes for %s", (name) => { + const node = element(); + const to = element(300, 100); + const handle = attachHandle(name, node, to); + const initial = paths(); + updateHandle(handle, { ...baseOptions(name), roughness: 0.25 }); + const rougher = paths(); + expect(rougher).not.toEqual(initial); + updateHandle(handle, { ...baseOptions(name), seed: 9 }); + expect(paths()).not.toEqual(rougher); + handle.destroy(); + }); + + it.each(names)("adds and removes boil variants for %s", (name) => { + const node = element(); + const to = element(300, 100); + const handle = attachHandle(name, node, to); + const before = paths().length; + updateHandle(handle, { ...baseOptions(name), boil: 0.4 }); + expect(paths().length).toBeGreaterThan(before); + updateHandle(handle, { ...baseOptions(name), boil: 0 }); + expect(paths().length).toBe(before); + handle.destroy(); + }); + + it.each(["circle", "underline", "highlight", "mark"] as const)( + "updates padding for %s", + (name) => { + const node = element(); + const handle = attachHandle(name, node, node); + const before = parseFloat(overlay().style.width); + updateHandle(handle, { ...baseOptions(name), padding: 30 }); + expect(parseFloat(overlay().style.width)).toBeGreaterThan(before); + updateHandle(handle, { ...baseOptions(name) }); + expect(parseFloat(overlay().style.width)).toBe(before); + handle.destroy(); + }, + ); + + it.each(names)("adds and removes descriptions for %s while preserving foreign IDs", (name) => { + const node = element(); + const to = element(300, 100); + node.setAttribute("aria-describedby", "native"); + const handle = attachHandle(name, node, to); + const target = name === "arrow" ? to : node; + const owned = (el: Element) => + el.getAttribute("aria-describedby")?.match(/stet-description-/g)?.length ?? 0; + target.setAttribute("aria-describedby", "native"); + updateHandle(handle, { ...baseOptions(name), description: "Described" }); + const withDescription = owned(target); + expect(withDescription).toBeGreaterThan(0); + expect(document.querySelector(".stet-description")?.textContent).toBe("Described"); + expect(target.getAttribute("aria-describedby")).toContain("native"); + updateHandle(handle, { ...baseOptions(name) }); + expect(owned(target)).toBe(withDescription - 1); + expect(target.getAttribute("aria-describedby")).toContain("native"); + handle.destroy(); + }); + + it.each(names)("toggles hover resketch listeners for %s", (name) => { + vi.spyOn(Math, "random").mockReturnValue(0.9); + const node = element(); + const to = element(300, 100); + const handle = attachHandle(name, node, to); + const before = paths(); + updateHandle(handle, { ...baseOptions(name), resketchOnHover: true }); + node.dispatchEvent(new PointerEvent("pointerenter")); + const resketched = paths(); + expect(resketched).not.toEqual(before); + updateHandle(handle, { ...baseOptions(name), resketchOnHover: false }); + node.dispatchEvent(new PointerEvent("pointerenter")); + expect(paths()).toEqual(resketched); + handle.destroy(); + }); + + it("adds, changes, and removes a description while preserving foreign IDs", () => { + const node = element(); + node.setAttribute("aria-describedby", "native"); + const handle = circle(node, { seed: 1 }); + updateHandle(handle, { seed: 1, description: "One" }); + const linked = node.getAttribute("aria-describedby")!; + expect(linked.startsWith("native ")).toBe(true); + updateHandle(handle, { seed: 1, description: "Two" }); + expect(node.getAttribute("aria-describedby")).toBe(linked); + expect(document.querySelector(".stet-description")?.textContent).toBe("Two"); + updateHandle(handle, { seed: 1 }); + expect(node.getAttribute("aria-describedby")).toBe("native"); + handle.destroy(); + }); + + it("preserves explicit hidden state and ARIA safety across description updates", async () => { + const node = element(); + node.setAttribute("aria-describedby", "native"); + const handle = circle(node, { seed: 1, visible: false, description: "Old" }); + expect(overlay().hidden).toBe(true); + updateHandle(handle, { seed: 1, description: "New" }); + expect(overlay().hidden).toBe(true); + expect(node.getAttribute("aria-describedby")).toBe("native"); + expect(document.querySelector(".stet-description")?.textContent).toBe("New"); + await expect(handle.show()).resolves.toEqual({ status: "finished" }); + expect(overlay().hidden).toBe(false); + expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); + expect(document.querySelector(".stet-description")?.textContent).toBe("New"); + handle.hide(); + updateHandle(handle, { seed: 1 }); + await expect(handle.show()).resolves.toEqual({ status: "finished" }); + expect(node.getAttribute("aria-describedby")).toBe("native"); + handle.destroy(); + }); + + it("ignores visible through the internal update path", () => { + const node = element(); + const visible = circle(node, { seed: 1 }); + updateHandle(visible, { seed: 1, visible: false }); + expect(overlay().hidden).toBe(false); + visible.destroy(); + document.body.replaceChildren(); + + const hiddenNode = element(); + const hidden = circle(hiddenNode, { seed: 1, visible: false }); + updateHandle(hidden, { seed: 1, visible: true }); + expect(overlay().hidden).toBe(true); + hidden.destroy(); + }); + + it("preserves the current operation promise, progress, and deadline", async () => { + vi.useFakeTimers(); + const handle = circle(element(), { seed: 1, animate: true, animationDuration: 600 }); + const op = handle.show(); + vi.advanceTimersByTime(300); + const before = Number(offset()); + updateHandle(handle, { seed: 1, stroke: "red" }); + expect(handle.show()).toBe(op); + expect(Number(offset())).toBeLessThanOrEqual(before); + expect(overlay().style.getPropertyValue("--stet-ink")).toBe("red"); + vi.advanceTimersByTime(310); + await expect(op).resolves.toEqual({ status: "finished" }); + handle.destroy(); + }); + + it("redraws at current progress on a seed change without restarting the operation", async () => { + vi.useFakeTimers(); + const handle = circle(element(), { seed: 1, animate: true, animationDuration: 600 }); + const op = handle.show(); + vi.advanceTimersByTime(300); + const before = Number(offset()); + const beforeD = path().getAttribute("d"); + updateHandle(handle, { seed: 2 }); + expect(path().getAttribute("d")).not.toBe(beforeD); + expect(Number(offset())).toBeLessThanOrEqual(before); + vi.advanceTimersByTime(310); + await expect(op).resolves.toEqual({ status: "finished" }); + handle.destroy(); + }); + + it("keeps the resketched seed when the seed option is unchanged", () => { + const handle = circle(element(), { seed: 1 }); + handle.resketch(99); + const resketched = paths(); + updateHandle(handle, { seed: 1, stroke: "red" }); + expect(paths()).toEqual(resketched); + handle.destroy(); + }); + + it("applies animation updates to the next entrance only", async () => { + vi.useFakeTimers(); + const handle = circle(element(), { seed: 1, animate: true, animationDuration: 600 }); + const op = handle.show(); + vi.advanceTimersByTime(200); + updateHandle(handle, { seed: 1, animate: true, animationDuration: 100, animationDelay: 0 }); + // The current operation keeps its original 600 ms deadline. + vi.advanceTimersByTime(100); + expect(offset()).not.toBeNull(); + await vi.advanceTimersByTimeAsync(400); + await expect(op).resolves.toEqual({ status: "finished" }); + // The next entrance uses the updated duration. + const replay = handle.replay(); + vi.advanceTimersByTime(100); + await expect(replay).resolves.toEqual({ status: "finished" }); + handle.destroy(); + }); + + it("updates mark kind and rejects invalid kinds before mutation", () => { + const node = element(); + const handle = mark(node, "right", { seed: 1 }); + expect(document.querySelector(".stet-mark--right")).not.toBeNull(); + updateHandle(handle, { seed: 1, kind: "wrong" }); + expect(document.querySelector(".stet-mark--wrong")).not.toBeNull(); + expect(document.querySelector(".stet-mark--right")).toBeNull(); + const before = paths(); + expect(() => updateHandle(handle, { seed: 1, kind: "maybe" })).toThrow("mark kind"); + expect(paths()).toEqual(before); + handle.destroy(); + }); + + it("updates arrow label, curvature, and offsets and removes label ARIA safely", async () => { + const from = element(); + const to = element(300, 100); + to.setAttribute("aria-describedby", "native"); + const handle = arrow(from, to, { seed: 1 }); + expect(document.querySelector(".stet-label")).toBeNull(); + const beforePath = paths()[0]; + updateHandle(handle, { seed: 1, label: "Two", curvature: 0.4 }); + expect(document.querySelector(".stet-label")?.textContent).toBe("Two"); + expect(paths()[0]).not.toBe(beforePath); + expect(to.getAttribute("aria-describedby")).toContain("stet-description-"); + const label = document.querySelector(".stet-label")!; + const baseline = [parseFloat(label.style.left), parseFloat(label.style.top)]; + updateHandle(handle, { + seed: 1, + label: "Two", + curvature: 0.4, + labelOffsetX: 5, + labelOffsetY: 6, + }); + expect([parseFloat(label.style.left), parseFloat(label.style.top)]).toEqual([ + baseline[0] + 5, + baseline[1] + 6, + ]); + updateHandle(handle, { seed: 1, curvature: 0.4 }); + expect(document.querySelector(".stet-label")).toBeNull(); + expect(to.getAttribute("aria-describedby")).toBe("native"); + // A later hide/show must not resurrect the removed relationship. + handle.hide(); + await expect(handle.show()).resolves.toEqual({ status: "finished" }); + expect(to.getAttribute("aria-describedby")).toBe("native"); + handle.destroy(); + }); + + it("updates sticky text, side, and offsets", () => { + const node = element(300, 300, 100, 30); + node.setAttribute("aria-describedby", "native"); + const handle = sticky(node, { text: "One", side: "right", seed: 1 }); + expect(document.querySelector(".stet-sticky-text")?.textContent).toBe("One"); + const baseline = parseFloat(overlay().style.left); + const baselineTop = parseFloat(overlay().style.top); + updateHandle(handle, { text: "Two", side: "right", seed: 1, offsetX: 25, offsetY: -20 }); + expect(document.querySelector(".stet-sticky-text")?.textContent).toBe("Two"); + expect(parseFloat(overlay().style.left)).toBe(baseline + 25); + expect(parseFloat(overlay().style.top)).toBe(baselineTop - 20); + expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); + updateHandle(handle, { text: "Two", side: "top", seed: 1 }); + expect(parseFloat(overlay().style.top)).toBeLessThan(300); + handle.destroy(); + }); + + it("updates arrow and sticky text while explicitly hidden without exposing ARIA", async () => { + const from = element(); + const to = element(300, 100); + const arrowHandle = arrow(from, to, { seed: 1, visible: false }); + updateHandle(arrowHandle, { seed: 1, label: "Hidden label" }); + expect(document.querySelector(".stet-label")?.textContent).toBe("Hidden label"); + expect(to.hasAttribute("aria-describedby")).toBe(false); + await arrowHandle.show(); + expect(to.getAttribute("aria-describedby")).toContain("stet-description-"); + arrowHandle.destroy(); + document.body.replaceChildren(); + + const node = element(); + const stickyHandle = sticky(node, { seed: 1, text: "One", visible: false }); + updateHandle(stickyHandle, { seed: 1, text: "Two" }); + expect(document.querySelector(".stet-sticky-text")?.textContent).toBe("Two"); + expect(node.hasAttribute("aria-describedby")).toBe(false); + await stickyHandle.show(); + expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); + stickyHandle.destroy(); + }); + + it("updates owned text while culled without treating culling as explicit hide", () => { + const from = element(20, 1800); + const to = element(300, 2000); + to.setAttribute("aria-describedby", "native"); + const arrowHandle = arrow(from, to, { seed: 1 }); + expect(overlay().hidden).toBe(true); + updateHandle(arrowHandle, { seed: 1, label: "Offscreen label" }); + expect(overlay().hidden).toBe(true); + expect(document.querySelector(".stet-label")?.textContent).toBe("Offscreen label"); + expect(to.getAttribute("aria-describedby")).toContain("stet-description-"); + updateHandle(arrowHandle, { seed: 1 }); + expect(to.getAttribute("aria-describedby")).toBe("native"); + arrowHandle.destroy(); + document.body.replaceChildren(); + + const node = element(20, 2000); + const stickyHandle = sticky(node, { seed: 1, text: "One" }); + updateHandle(stickyHandle, { seed: 1, text: "Two" }); + expect(overlay().hidden).toBe(true); + expect(document.querySelector(".stet-sticky-text")?.textContent).toBe("Two"); + expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); + stickyHandle.destroy(); + }); + + it("validates timing, support, and required text before mutating", () => { + const node = element(); + node.setAttribute("aria-describedby", "native"); + const handle = circle(node, { seed: 1, description: "Keep" }); + const before = { + paths: paths(), + aria: node.getAttribute("aria-describedby"), + left: overlay().style.left, + }; + expect(() => updateHandle(handle, { seed: 2, animationDuration: -1 })).toThrow(RangeError); + expect(paths()).toEqual(before.paths); + expect(node.getAttribute("aria-describedby")).toBe(before.aria); + expect(overlay().style.left).toBe(before.left); + handle.destroy(); + document.body.replaceChildren(); + + const highlightHandle = highlight(element(), { seed: 1 }); + expect(() => updateHandle(highlightHandle, { seed: 1, animate: true })).toThrow(TypeError); + highlightHandle.destroy(); + document.body.replaceChildren(); + + const stickyHandle = sticky(element(), { text: "Note", seed: 1 }); + expect(() => updateHandle(stickyHandle, { seed: 1 })).toThrow("sticky text"); + stickyHandle.destroy(); + }); + + it("no-ops on destroyed handles", () => { + const handle = circle(element(), { seed: 1 }); + handle.destroy(); + expect(() => updateHandle(handle, { seed: 2 })).not.toThrow(); + }); + + it("keeps update off public handles and root exports", () => { + expect("updateHandle" in stet).toBe(false); + expect("update" in stet).toBe(false); + const node = element(); + const to = element(300, 100); + for (const name of names) { + const handle = attachHandle(name, node, to); + expect("update" in handle).toBe(false); + expect(Object.keys(handle)).not.toContain("update"); + expect(typeof (handle as unknown as Record).update).toBe("undefined"); + handle.destroy(); + } + }); +}); From 397097ed8296ea7be18c1b40beae92c1f20e64cd Mon Sep 17 00:00:00 2001 From: Sai Nimmagadda Date: Sun, 13 Sep 2026 11:36:36 -0400 Subject: [PATCH 06/11] feat: phase 4 and 5 --- README.md | 1 + agent/annotation-plan.d.ts | 25 + agent/capabilities.json | 83 +- agent/catalog.mjs | 9 +- agent/evals/plans.json | 2 +- agent/evals/routing.json | 16 + agent/patterns.mjs | 88 +- agent/schemas/annotation-plan.schema.json | 137 +++ agent/schemas/capabilities.schema.json | 83 +- .../stet-explain-ui/evals/trigger_evals.json | 20 + .../stet-review-ui/evals/trigger_evals.json | 20 + .../stet-showcase-ui/evals/trigger_evals.json | 20 + agent/skills/stet/SKILL.md | 39 +- agent/skills/stet/evals/trigger_evals.json | 20 + agent/skills/stet/references/delivery.md | 44 + agent/skills/stet/references/shipping.md | 26 + agent/snippets.mjs | 64 +- agent/templates/angular/arrow.ts | 8 +- agent/templates/angular/box.ts | 17 + agent/templates/angular/circle.ts | 8 +- agent/templates/angular/highlight.ts | 8 +- agent/templates/angular/lifecycle.ts | 15 +- agent/templates/angular/mark.ts | 8 +- agent/templates/angular/sticky.ts | 8 +- agent/templates/angular/underline.ts | 8 +- agent/templates/react/arrow.tsx | 7 +- agent/templates/react/box.tsx | 16 + agent/templates/react/circle.tsx | 7 +- agent/templates/react/highlight.tsx | 7 +- agent/templates/react/lifecycle.tsx | 15 +- agent/templates/react/mark.tsx | 7 +- agent/templates/react/sticky.tsx | 7 +- agent/templates/react/underline.tsx | 7 +- agent/templates/svelte/arrow.svelte | 15 +- agent/templates/svelte/box.svelte | 10 + agent/templates/svelte/circle.svelte | 5 +- agent/templates/svelte/highlight.svelte | 5 +- agent/templates/svelte/lifecycle.svelte | 15 +- agent/templates/svelte/mark.svelte | 5 +- agent/templates/svelte/sticky.svelte | 5 +- agent/templates/svelte/underline.svelte | 5 +- agent/templates/vanilla/box.ts | 9 + agent/templates/vanilla/lifecycle.ts | 15 +- agent/templates/vue/arrow.vue | 23 +- agent/templates/vue/box.vue | 13 + agent/templates/vue/circle.vue | 6 +- agent/templates/vue/highlight.vue | 6 +- agent/templates/vue/lifecycle.vue | 23 +- agent/templates/vue/mark.vue | 6 +- agent/templates/vue/sticky.vue | 6 +- agent/templates/vue/underline.vue | 6 +- agent/validate.generated.mjs | 2 +- docs/README.md | 1 + docs/agent-architecture.md | 2 +- docs/agent-verification.md | 12 +- docs/delivery/backlog.md | 52 +- docs/delivery/evidence/AD-ANGULAR.md | 11 + docs/delivery/evidence/AD-INTEGRATE.md | 10 + docs/delivery/evidence/AD-REACT.md | 11 + docs/delivery/evidence/AD-SVELTE.md | 11 + docs/delivery/evidence/AD-VUE.md | 11 + .../delivery/evidence/AGENT-01-PROVISIONAL.md | 11 + docs/delivery/evidence/AGENT-01.md | 9 + docs/delivery/evidence/AGENT-02.md | 9 + docs/delivery/evidence/AGENT-03.md | 9 + docs/delivery/evidence/BOX-01.md | 9 + docs/delivery/evidence/BOX-02.md | 10 + docs/delivery/evidence/CORE-08.md | 11 + docs/delivery/evidence/PW-01.md | 13 + docs/delivery/evidence/PW-02.md | 14 + docs/delivery/evidence/PW-03.md | 12 + docs/delivery/evidence/PW-04.md | 12 + docs/delivery/evidence/PW-05.md | 13 + docs/delivery/evidence/PW-06.md | 13 + docs/delivery/evidence/PW-07.md | 11 + docs/delivery/evidence/PW-08.md | 12 + docs/delivery/evidence/SHIP-01.md | 12 + docs/delivery/evidence/SHIP-02.md | 10 + docs/delivery/evidence/SHIP-03.md | 12 + docs/delivery/tasks/AD-ANGULAR.md | 4 +- docs/delivery/tasks/AD-INTEGRATE.md | 2 +- docs/delivery/tasks/AD-REACT.md | 2 +- docs/delivery/tasks/AD-SVELTE.md | 2 +- docs/delivery/tasks/AD-VUE.md | 2 +- docs/delivery/tasks/AGENT-01.md | 6 +- docs/delivery/tasks/AGENT-02.md | 2 +- docs/delivery/tasks/AGENT-03.md | 2 +- docs/delivery/tasks/BOX-01.md | 2 +- docs/delivery/tasks/BOX-02.md | 2 +- docs/delivery/tasks/CORE-08.md | 2 +- docs/delivery/tasks/PW-01.md | 2 +- docs/delivery/tasks/PW-02.md | 2 +- docs/delivery/tasks/PW-03.md | 2 +- docs/delivery/tasks/PW-04.md | 2 +- docs/delivery/tasks/PW-05.md | 2 +- docs/delivery/tasks/PW-06.md | 2 +- docs/delivery/tasks/PW-07.md | 2 +- docs/delivery/tasks/PW-08.md | 2 +- docs/delivery/tasks/SHIP-01.md | 2 +- docs/delivery/tasks/SHIP-02.md | 2 +- docs/delivery/tasks/SHIP-03.md | 2 +- docs/playwright.md | 61 ++ docs/product-direction.md | 14 +- docs/shipping.md | 95 ++ examples/playwright/behavior.css | 4 + examples/playwright/behavior.html | 22 + examples/playwright/behavior.js | 13 + examples/playwright/index.html | 20 + examples/playwright/source.js | 9 + examples/playwright/style.css | 12 + package-lock.json | 4 + package.json | 10 +- playwright.pw01.config.ts | 20 + playwright.pw02.config.ts | 20 + playwright.pw04.config.ts | 18 + playwright.pw05.config.ts | 18 + playwright.pw06.config.ts | 18 + playwright.pw07.config.ts | 19 + playwright.pw08.config.ts | 14 + playwright.shipping.config.ts | 35 + scripts-size.mjs | 6 +- scripts/agent/check-package.mjs | 12 +- scripts/agent/check-templates.mjs | 67 +- scripts/agent/generate.mjs | 5 +- scripts/build-playwright.mjs | 56 ++ src/angular.ts | 272 +++++- src/index.ts | 2 +- src/mount.ts | 4 +- src/playwright/entry.ts | 580 ++++++++++++ src/primitives.ts | 23 + src/react.ts | 284 ++++-- src/rough.ts | 25 + src/svelte.ts | 258 +++++- src/vue.ts | 254 ++++- tests/agent/cli.test.ts | 200 ++-- tests/agent/patterns.test.ts | 59 +- tests/agent/schema.test.ts | 27 +- tests/angular.test.ts | 502 ++++++++++ tests/browser/reveal.spec.ts | 12 +- tests/browser/visual.spec.ts | 70 +- .../box-geometry-chromium-linux.png | Bin 0 -> 3448 bytes .../box-geometry-firefox-linux.png | Bin 0 -> 6330 bytes tests/pw01/build-payload.mjs | 27 + tests/pw01/fixtures/authored.js | 11 + tests/pw01/fixtures/base.css | 48 + tests/pw01/fixtures/blocked.html | 14 + tests/pw01/fixtures/coexist.html | 21 + tests/pw01/fixtures/foreign.css | 4 + tests/pw01/fixtures/frame-outer.html | 16 + tests/pw01/fixtures/open.html | 11 + tests/pw01/fixtures/strict-inner.html | 15 + tests/pw01/fixtures/strict.html | 16 + tests/pw01/global-setup.ts | 14 + tests/pw01/loading.spec.ts | 224 +++++ tests/pw01/stet-loader.ts | 215 +++++ tests/pw02/build-payload.mjs | 51 + tests/pw02/emulated-entry.mjs | 218 +++++ tests/pw02/fixtures/authored.js | 13 + tests/pw02/fixtures/base.css | 42 + tests/pw02/fixtures/coexist.html | 22 + tests/pw02/fixtures/foreign.css | 4 + tests/pw02/fixtures/strict-b.html | 15 + tests/pw02/fixtures/strict.html | 16 + tests/pw02/global-setup.ts | 21 + tests/pw02/packed.spec.ts | 202 ++++ tests/pw02/prepare-consumer.mjs | 158 ++++ tests/pw04/fixtures/authored.js | 4 + tests/pw04/fixtures/base.css | 4 + tests/pw04/fixtures/frame.html | 11 + tests/pw04/fixtures/main.html | 18 + tests/pw04/global-setup.ts | 7 + tests/pw04/sessions.spec.ts | 129 +++ tests/pw05/fixtures/authored.js | 3 + tests/pw05/fixtures/frame.html | 1 + tests/pw05/fixtures/main.html | 16 + tests/pw05/fixtures/next.html | 1 + tests/pw05/global-setup.ts | 9 + tests/pw05/lifecycle.spec.ts | 124 +++ tests/pw06/fixtures/blocked.html | 2 + tests/pw06/fixtures/frame.html | 2 + tests/pw06/fixtures/next.html | 1 + tests/pw06/fixtures/page.css | 2 + tests/pw06/fixtures/strict.html | 14 + tests/pw06/global-setup.ts | 9 + tests/pw06/matrix.spec.ts | 86 ++ tests/pw07/capture.spec.ts | 31 + tests/pw07/global-setup.ts | 9 + tests/pw08/behavior.spec.ts | 66 ++ tests/pw08/global-setup.ts | 11 + tests/pw08/prepare-consumer.mjs | 32 + tests/react.test.ts | 506 +++++++++- tests/rough.test.ts | 31 +- .../shipping/fixture/apps/disabled/index.html | 1 + tests/shipping/fixture/apps/disabled/main.ts | 2 + tests/shipping/fixture/apps/mixed/index.html | 1 + tests/shipping/fixture/apps/mixed/main.ts | 6 + .../fixture/apps/production/index.html | 1 + .../shipping/fixture/apps/production/main.ts | 4 + tests/shipping/fixture/apps/review/index.html | 1 + tests/shipping/fixture/apps/review/main.ts | 4 + tests/shipping/fixture/config-base.mjs | 20 + tests/shipping/fixture/src/app.ts | 6 + tests/shipping/fixture/src/stet/production.ts | 12 + .../fixture/src/stet/review-boundary.ts | 7 + .../fixture/src/stet/review-plan.json | 1 + tests/shipping/fixture/src/stet/review.css | 1 + tests/shipping/fixture/src/stet/review.ts | 16 + .../shipping/fixture/vite.disabled.config.mjs | 2 + tests/shipping/fixture/vite.local.config.mjs | 2 + .../fixture/vite.mixed-local.config.mjs | 2 + .../fixture/vite.mixed-preview.config.mjs | 2 + .../shipping/fixture/vite.preview.config.mjs | 2 + .../fixture/vite.production.config.mjs | 2 + tests/shipping/inspect-output.mjs | 153 +++ tests/shipping/prepare.mjs | 86 ++ tests/shipping/runtime.spec.ts | 29 + tests/stet.test.ts | 873 +++++++++--------- tests/svelte.test.ts | 309 ++++++- tests/vue.test.ts | 436 ++++++++- 219 files changed, 8147 insertions(+), 908 deletions(-) create mode 100644 agent/skills/stet/references/delivery.md create mode 100644 agent/skills/stet/references/shipping.md create mode 100644 agent/templates/angular/box.ts create mode 100644 agent/templates/react/box.tsx create mode 100644 agent/templates/svelte/box.svelte create mode 100644 agent/templates/vanilla/box.ts create mode 100644 agent/templates/vue/box.vue create mode 100644 docs/delivery/evidence/AD-ANGULAR.md create mode 100644 docs/delivery/evidence/AD-INTEGRATE.md create mode 100644 docs/delivery/evidence/AD-REACT.md create mode 100644 docs/delivery/evidence/AD-SVELTE.md create mode 100644 docs/delivery/evidence/AD-VUE.md create mode 100644 docs/delivery/evidence/AGENT-01-PROVISIONAL.md create mode 100644 docs/delivery/evidence/AGENT-01.md create mode 100644 docs/delivery/evidence/AGENT-02.md create mode 100644 docs/delivery/evidence/AGENT-03.md create mode 100644 docs/delivery/evidence/BOX-01.md create mode 100644 docs/delivery/evidence/BOX-02.md create mode 100644 docs/delivery/evidence/CORE-08.md create mode 100644 docs/delivery/evidence/PW-01.md create mode 100644 docs/delivery/evidence/PW-02.md create mode 100644 docs/delivery/evidence/PW-03.md create mode 100644 docs/delivery/evidence/PW-04.md create mode 100644 docs/delivery/evidence/PW-05.md create mode 100644 docs/delivery/evidence/PW-06.md create mode 100644 docs/delivery/evidence/PW-07.md create mode 100644 docs/delivery/evidence/PW-08.md create mode 100644 docs/delivery/evidence/SHIP-01.md create mode 100644 docs/delivery/evidence/SHIP-02.md create mode 100644 docs/delivery/evidence/SHIP-03.md create mode 100644 docs/playwright.md create mode 100644 docs/shipping.md create mode 100644 examples/playwright/behavior.css create mode 100644 examples/playwright/behavior.html create mode 100644 examples/playwright/behavior.js create mode 100644 examples/playwright/index.html create mode 100644 examples/playwright/source.js create mode 100644 examples/playwright/style.css create mode 100644 playwright.pw01.config.ts create mode 100644 playwright.pw02.config.ts create mode 100644 playwright.pw04.config.ts create mode 100644 playwright.pw05.config.ts create mode 100644 playwright.pw06.config.ts create mode 100644 playwright.pw07.config.ts create mode 100644 playwright.pw08.config.ts create mode 100644 playwright.shipping.config.ts create mode 100644 scripts/build-playwright.mjs create mode 100644 src/playwright/entry.ts create mode 100644 tests/angular.test.ts create mode 100644 tests/browser/visual.spec.ts-snapshots/box-geometry-chromium-linux.png create mode 100644 tests/browser/visual.spec.ts-snapshots/box-geometry-firefox-linux.png create mode 100644 tests/pw01/build-payload.mjs create mode 100644 tests/pw01/fixtures/authored.js create mode 100644 tests/pw01/fixtures/base.css create mode 100644 tests/pw01/fixtures/blocked.html create mode 100644 tests/pw01/fixtures/coexist.html create mode 100644 tests/pw01/fixtures/foreign.css create mode 100644 tests/pw01/fixtures/frame-outer.html create mode 100644 tests/pw01/fixtures/open.html create mode 100644 tests/pw01/fixtures/strict-inner.html create mode 100644 tests/pw01/fixtures/strict.html create mode 100644 tests/pw01/global-setup.ts create mode 100644 tests/pw01/loading.spec.ts create mode 100644 tests/pw01/stet-loader.ts create mode 100644 tests/pw02/build-payload.mjs create mode 100644 tests/pw02/emulated-entry.mjs create mode 100644 tests/pw02/fixtures/authored.js create mode 100644 tests/pw02/fixtures/base.css create mode 100644 tests/pw02/fixtures/coexist.html create mode 100644 tests/pw02/fixtures/foreign.css create mode 100644 tests/pw02/fixtures/strict-b.html create mode 100644 tests/pw02/fixtures/strict.html create mode 100644 tests/pw02/global-setup.ts create mode 100644 tests/pw02/packed.spec.ts create mode 100644 tests/pw02/prepare-consumer.mjs create mode 100644 tests/pw04/fixtures/authored.js create mode 100644 tests/pw04/fixtures/base.css create mode 100644 tests/pw04/fixtures/frame.html create mode 100644 tests/pw04/fixtures/main.html create mode 100644 tests/pw04/global-setup.ts create mode 100644 tests/pw04/sessions.spec.ts create mode 100644 tests/pw05/fixtures/authored.js create mode 100644 tests/pw05/fixtures/frame.html create mode 100644 tests/pw05/fixtures/main.html create mode 100644 tests/pw05/fixtures/next.html create mode 100644 tests/pw05/global-setup.ts create mode 100644 tests/pw05/lifecycle.spec.ts create mode 100644 tests/pw06/fixtures/blocked.html create mode 100644 tests/pw06/fixtures/frame.html create mode 100644 tests/pw06/fixtures/next.html create mode 100644 tests/pw06/fixtures/page.css create mode 100644 tests/pw06/fixtures/strict.html create mode 100644 tests/pw06/global-setup.ts create mode 100644 tests/pw06/matrix.spec.ts create mode 100644 tests/pw07/capture.spec.ts create mode 100644 tests/pw07/global-setup.ts create mode 100644 tests/pw08/behavior.spec.ts create mode 100644 tests/pw08/global-setup.ts create mode 100644 tests/pw08/prepare-consumer.mjs create mode 100644 tests/shipping/fixture/apps/disabled/index.html create mode 100644 tests/shipping/fixture/apps/disabled/main.ts create mode 100644 tests/shipping/fixture/apps/mixed/index.html create mode 100644 tests/shipping/fixture/apps/mixed/main.ts create mode 100644 tests/shipping/fixture/apps/production/index.html create mode 100644 tests/shipping/fixture/apps/production/main.ts create mode 100644 tests/shipping/fixture/apps/review/index.html create mode 100644 tests/shipping/fixture/apps/review/main.ts create mode 100644 tests/shipping/fixture/config-base.mjs create mode 100644 tests/shipping/fixture/src/app.ts create mode 100644 tests/shipping/fixture/src/stet/production.ts create mode 100644 tests/shipping/fixture/src/stet/review-boundary.ts create mode 100644 tests/shipping/fixture/src/stet/review-plan.json create mode 100644 tests/shipping/fixture/src/stet/review.css create mode 100644 tests/shipping/fixture/src/stet/review.ts create mode 100644 tests/shipping/fixture/vite.disabled.config.mjs create mode 100644 tests/shipping/fixture/vite.local.config.mjs create mode 100644 tests/shipping/fixture/vite.mixed-local.config.mjs create mode 100644 tests/shipping/fixture/vite.mixed-preview.config.mjs create mode 100644 tests/shipping/fixture/vite.preview.config.mjs create mode 100644 tests/shipping/fixture/vite.production.config.mjs create mode 100644 tests/shipping/inspect-output.mjs create mode 100644 tests/shipping/prepare.mjs create mode 100644 tests/shipping/runtime.spec.ts diff --git a/README.md b/README.md index 238d14b..2ab37d1 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ To explore this checkout locally, run `npm ci`, `npm --prefix website ci`, then `npm --prefix website run dev`. Open `/use-cases` or `/agent-workflow`. The [playground](https://www.stetkit.com/playground) exposes real options and copyable code; framework integration lives in [Docs](https://www.stetkit.com/docs). +Use the [tested Vite boundary](docs/shipping.md) to exclude temporary review annotations. See [website setup](website/README.md) for production builds and tests. Hosted links show the deployed site; this checkout's additions appear after deployment. The [fixed visual specimens](examples/visual/) remain the regression matrix. diff --git a/agent/annotation-plan.d.ts b/agent/annotation-plan.d.ts index 50c102d..ab94e25 100644 --- a/agent/annotation-plan.d.ts +++ b/agent/annotation-plan.d.ts @@ -5,6 +5,31 @@ export type AnnotationPlan = { "intent": string; "annotations": Array<{ "id": string; +"primitive": "box"; +"targets": [{ +"strategy": "ref" | "id" | "data-attribute" | "source" | "stet-attribute" | "css"; +"file": string; +"locator": string; +"description": string; +"rationale"?: string; +}]; +"options"?: { +"seed"?: number; +"roughness"?: number; +"boil"?: number; +"stroke"?: string; +"fill"?: string; +"width"?: number; +"resketchOnHover"?: boolean; +"padding"?: number; +"visible"?: boolean; +"animate"?: boolean; +"animationDuration"?: number; +"animationDelay"?: number; +"description"?: string; +}; +} | { +"id": string; "primitive": "circle"; "targets": [{ "strategy": "ref" | "id" | "data-attribute" | "source" | "stet-attribute" | "css"; diff --git a/agent/capabilities.json b/agent/capabilities.json index 5b5febf..5e2a976 100644 --- a/agent/capabilities.json +++ b/agent/capabilities.json @@ -9,6 +9,7 @@ "./vue", "./svelte", "./angular", + "./playwright", "./style.css", "./agent", "./agent/capabilities.json", @@ -23,6 +24,7 @@ "lifecycle": "Attach after DOM mount; destroy before removal; destroy and reattach to change options.", "import": "@funsaized/stet", "symbols": { + "box": "box", "circle": "circle", "underline": "underline", "highlight": "highlight", @@ -38,6 +40,7 @@ "lifecycle": "Effects attach after commit and clean up; ref changes require a React commit.", "import": "@funsaized/stet/react", "symbols": { + "box": "Box", "circle": "Circle", "underline": "Underline", "highlight": "Highlight", @@ -53,6 +56,7 @@ "lifecycle": "mounted/updated/unmounted; mount arrow host only after destination exists.", "import": "@funsaized/stet/vue", "symbols": { + "box": "vStetBox", "circle": "vStetCircle", "underline": "vStetUnderline", "highlight": "vStetHighlight", @@ -68,6 +72,7 @@ "lifecycle": "Action update/destroy; mount arrow host only after destination exists.", "import": "@funsaized/stet/svelte", "symbols": { + "box": "box", "circle": "circle", "underline": "underline", "highlight": "highlight", @@ -83,6 +88,7 @@ "lifecycle": "afterNextRender/ngOnChanges/ngOnDestroy; replace input option objects on update.", "import": "@funsaized/stet/angular", "symbols": { + "box": "StetBoxDirective", "circle": "StetCircleDirective", "underline": "StetUnderlineDirective", "highlight": "StetHighlightDirective", @@ -93,6 +99,81 @@ } }, "primitives": { + "box": { + "type": "StetOptions", + "targets": [ + "target" + ], + "padding": 5, + "purpose": "Frame an existing element", + "meaningfulText": "description", + "targetType": "Element", + "options": { + "type": "object", + "additionalProperties": false, + "properties": { + "seed": { + "type": "number" + }, + "roughness": { + "type": "number" + }, + "boil": { + "type": "number" + }, + "stroke": { + "type": "string" + }, + "fill": { + "type": "string" + }, + "width": { + "type": "number" + }, + "resketchOnHover": { + "type": "boolean" + }, + "padding": { + "type": "number" + }, + "visible": { + "type": "boolean" + }, + "animate": { + "type": "boolean" + }, + "animationDuration": { + "type": "number", + "minimum": 0 + }, + "animationDelay": { + "type": "number", + "minimum": 0 + }, + "description": { + "type": "string", + "pattern": "\\S" + } + }, + "required": [] + }, + "defaults": { + "seed": "random uint32", + "roughness": 1, + "boil": 0, + "stroke": "CSS token", + "fill": "CSS token", + "width": "CSS token", + "resketchOnHover": false, + "padding": 5, + "visible": true, + "animate": false, + "animationDuration": 600, + "animationDelay": 0, + "description": "absent" + }, + "effects": "fill has no visible effect" + }, "circle": { "type": "StetOptions", "targets": [ @@ -592,7 +673,7 @@ "ownership": "Existing UI owns layout, semantics, focus, pointer interaction, forms and lifecycle. Annotations never replace controls.", "accessibility": "SVG is decorative. Supply description for meaningful marks. Sticky text and arrow labels are DOM text linked by aria-describedby. Do not use annotations as the only persistent safety warning.", "motion": "Still by default; reduced motion disables boil and hover resketch live. Fixed seed, dimensions and options reproduce geometry.", - "lifecycle": "DOM Elements in the current document and document.body must exist. Options are snapshots. Handles expose refresh(), resketch(seed?), destroy().", + "lifecycle": "DOM Elements in the current document and document.body must exist. Options are snapshots. Handles expose show(), hide(), replay(), refresh(), resketch(seed?), destroy().", "targeting": "Targets are source-editing evidence, not executable selectors. Prove uniqueness and readiness in application code. Arrow order is from, to.", "placement": "Body overlays; resize/scroll/font tracking. Movement without resize needs refresh(). No collision engine, cross-document targets, top-layer dialogs or transformed/zoomed body/html support. Sticky offsetX/offsetY and arrow labelOffsetX/labelOffsetY nudge automatic placement before viewport clamping; they do not avoid obstacles. Partial clipping and very long notes need visual review.", "styling": "Import @funsaized/stet/style.css once; preserve target layout and semantics. CSS variables theme the overlay." diff --git a/agent/catalog.mjs b/agent/catalog.mjs index 11295e0..13a0495 100644 --- a/agent/catalog.mjs +++ b/agent/catalog.mjs @@ -33,6 +33,13 @@ export const frameworks = { }, }; export const primitives = { + box: { + type: "StetOptions", + targets: ["target"], + padding: 5, + purpose: "Frame an existing element", + meaningfulText: "description", + }, circle: { type: "StetOptions", targets: ["target"], @@ -111,7 +118,7 @@ export const constraints = { motion: "Still by default; reduced motion disables boil and hover resketch live. Fixed seed, dimensions and options reproduce geometry.", lifecycle: - "DOM Elements in the current document and document.body must exist. Options are snapshots. Handles expose refresh(), resketch(seed?), destroy().", + "DOM Elements in the current document and document.body must exist. Options are snapshots. Handles expose show(), hide(), replay(), refresh(), resketch(seed?), destroy().", targeting: "Targets are source-editing evidence, not executable selectors. Prove uniqueness and readiness in application code. Arrow order is from, to.", placement: diff --git a/agent/evals/plans.json b/agent/evals/plans.json index 1e5b340..abab912 100644 --- a/agent/evals/plans.json +++ b/agent/evals/plans.json @@ -37,7 +37,7 @@ "annotations": [ { "id": "delete", - "primitive": "box", + "primitive": "bracket", "targets": [ { "strategy": "ref", diff --git a/agent/evals/routing.json b/agent/evals/routing.json index bb0a206..76fa717 100644 --- a/agent/evals/routing.json +++ b/agent/evals/routing.json @@ -82,5 +82,21 @@ { "query": "Mark what's wrong with these two form patterns", "expected": "stet-review-ui" + }, + { + "query": "Add durable Stet callouts to this published onboarding page", + "expected": "stet-explain-ui" + }, + { + "query": "Temporarily circle the Save button in a Playwright screenshot without editing application source", + "expected": "stet" + }, + { + "query": "Annotate this dashboard with Stet, but the intended lifetime is not specified", + "expected": "stet" + }, + { + "query": "Take a Playwright screenshot without Stet annotations", + "expected": "none" } ] diff --git a/agent/patterns.mjs b/agent/patterns.mjs index 17d5e8d..c14d883 100644 --- a/agent/patterns.mjs +++ b/agent/patterns.mjs @@ -1,23 +1,43 @@ -import { frameworks } from './catalog.mjs'; +import { frameworks } from "./catalog.mjs"; // Copyable component-local lifecycle examples, not an application runtime layer. export function lifecyclePattern(framework, arrowOnly = false) { - if (!Object.hasOwn(frameworks, framework)) throw new Error('Unknown framework'); - const helper = `import { arrow, circle, sticky, type StetHandle } from "@funsaized/stet"; + if (!Object.hasOwn(frameworks, framework)) throw new Error("Unknown framework"); + const helper = `import { arrow, box, circle, sticky, type StetHandle } from "@funsaized/stet"; function attachMarks(target: Element, destination: Element | null | undefined, enabled: boolean) { const handles: StetHandle[] = []; const destroy = () => { for (const handle of handles.splice(0).reverse()) handle.destroy(); }; try { if (enabled) { -${arrowOnly ? '' : ' handles.push(circle(target, { seed: 42, description: "Review this action before continuing." }));\n handles.push(sticky(target, { seed: 43, text: "Read the consequences before continuing." }));\n'} if (destination) handles.push(arrow(target, destination, { seed: 44, label: "Consequences are explained here.", labelOffsetY: -12 })); +${arrowOnly ? "" : ' handles.push(circle(target, { seed: 42, description: "Review this action before continuing." }));\n handles.push(sticky(target, { seed: 43, text: "Read the consequences before continuing." }));\n'} if (destination) handles.push(arrow(target, destination, { seed: 44, label: "Consequences are explained here.", labelOffsetY: -12 })); } return destroy; } catch (error) { destroy(); throw error; } } +// Start hidden and animate on show so host code sequences reveal and replay. +export function attachMotionBox(target: Element) { + return box(target, { seed: 45, visible: false, animate: true }); +} +// Reduced motion settles immediately through the runtime; framework cleanup and +// handle.destroy() cancel any active show/replay work. +export async function runMotionSequence(handle: StetHandle | null) { + if (!handle) return; + handle.hide(); + if ((await handle.show()).status === "cancelled") return; + if ((await handle.replay()).status === "cancelled") return; + handle.hide(); +} `; - const note = '// Adapt to existing controls; annotation state never controls their presence.\n'; + const note = "// Adapt to existing controls; annotation state never controls their presence.\n"; const css = 'import "@funsaized/stet/style.css";\n'; - if (framework === 'vanilla') return { extension: 'ts', code: note + helper + css + ` + if (framework === "vanilla") + return { + extension: "ts", + code: + note + + helper + + css + + ` // Call update after the application mounts/replaces/removes its destination. // Call destroy before removing the source control. Reattachment preserves seeds. export function annotate(target: Element) { @@ -30,8 +50,18 @@ export function annotate(target: Element) { destroy() { cleanup(); cleanup = () => {}; }, }; } -` }; - if (framework === 'react') return { extension: 'tsx', code: '"use client";\n' + note + 'import { useEffect, useRef } from "react";\n' + helper + css + ` +`, + }; + if (framework === "react") + return { + extension: "tsx", + code: + '"use client";\n' + + note + + 'import { useEffect, useRef } from "react";\n' + + helper + + css + + ` export function AnnotatedAction({ enabled = true, destination = 0 }: { enabled?: boolean; destination?: number }) { const target = useRef(null); const to = useRef(null); @@ -43,8 +73,19 @@ export function AnnotatedAction({ enabled = true, destination = 0 }: { enabled?: {destination > 0 &&

Consequences of this action

} ; } -` }; - if (framework === 'vue') return { extension: 'vue', code: '\n\n\n\n` }; - if (framework === 'svelte') return { extension: 'svelte', code: `\n\n${primitive === 'arrow' ? '

Consequences of this action

\n\n{#if destination}\n' : ''}\n${primitive === 'arrow' ? '{/if}\n' : ''}` }; - return { extension: 'ts', code: note + `import { Component } from "@angular/core";\nimport { Stet${title}Directive } from "@funsaized/stet/angular";\n// Add @import "@funsaized/stet/style.css"; to the application's global stylesheet.\n@Component({\n selector: "app-annotated-action",\n standalone: true,\n imports: [Stet${title}Directive],\n template: \`\n${primitive === 'arrow' ? '

Consequences of this action

\n' : ''} \n \`,\n})\nexport class AnnotatedAction {}\n` }; + if (framework === "vue") + return { + extension: "vue", + code: `\n\n\n`, + }; + if (framework === "svelte") + return { + extension: "svelte", + code: `\n\n${primitive === "arrow" ? "

Consequences of this action

\n\n{#if destination}\n" : ""}\n${primitive === "arrow" ? "{/if}\n" : ""}`, + }; + return { + extension: "ts", + code: + note + + `import { Component } from "@angular/core";\nimport type { StetHandle } from "@funsaized/stet";\nimport { Stet${title}Directive } from "@funsaized/stet/angular";\n// Add @import "@funsaized/stet/style.css"; to the application's global stylesheet.\n@Component({\n selector: "app-annotated-action",\n standalone: true,\n imports: [Stet${title}Directive],\n template: \`\n${primitive === "arrow" ? "

Consequences of this action

\n" : ""} \n \`,\n})\nexport class AnnotatedAction {\n handle: StetHandle | null = null;\n readonly onHandle = (next: StetHandle | null) => { this.handle = next; };\n}\n`, + }; } diff --git a/agent/templates/angular/arrow.ts b/agent/templates/angular/arrow.ts index 4fc32cf..5660e6a 100644 --- a/agent/templates/angular/arrow.ts +++ b/agent/templates/angular/arrow.ts @@ -1,5 +1,6 @@ // Adapt these sample elements to existing source; retain their semantics and handlers. import { Component } from "@angular/core"; +import type { StetHandle } from "@funsaized/stet"; import { StetArrowDirective } from "@funsaized/stet/angular"; // Add @import "@funsaized/stet/style.css"; to the application's global stylesheet. @Component({ @@ -8,7 +9,10 @@ import { StetArrowDirective } from "@funsaized/stet/angular"; imports: [StetArrowDirective], template: `

Consequences of this action

- + `, }) -export class AnnotatedAction {} +export class AnnotatedAction { + handle: StetHandle | null = null; + readonly onHandle = (next: StetHandle | null) => { this.handle = next; }; +} diff --git a/agent/templates/angular/box.ts b/agent/templates/angular/box.ts new file mode 100644 index 0000000..eb7a0cd --- /dev/null +++ b/agent/templates/angular/box.ts @@ -0,0 +1,17 @@ +// Adapt these sample elements to existing source; retain their semantics and handlers. +import { Component } from "@angular/core"; +import type { StetHandle } from "@funsaized/stet"; +import { StetBoxDirective } from "@funsaized/stet/angular"; +// Add @import "@funsaized/stet/style.css"; to the application's global stylesheet. +@Component({ + selector: "app-annotated-action", + standalone: true, + imports: [StetBoxDirective], + template: ` + + `, +}) +export class AnnotatedAction { + handle: StetHandle | null = null; + readonly onHandle = (next: StetHandle | null) => { this.handle = next; }; +} diff --git a/agent/templates/angular/circle.ts b/agent/templates/angular/circle.ts index 2545784..1aa8117 100644 --- a/agent/templates/angular/circle.ts +++ b/agent/templates/angular/circle.ts @@ -1,5 +1,6 @@ // Adapt these sample elements to existing source; retain their semantics and handlers. import { Component } from "@angular/core"; +import type { StetHandle } from "@funsaized/stet"; import { StetCircleDirective } from "@funsaized/stet/angular"; // Add @import "@funsaized/stet/style.css"; to the application's global stylesheet. @Component({ @@ -7,7 +8,10 @@ import { StetCircleDirective } from "@funsaized/stet/angular"; standalone: true, imports: [StetCircleDirective], template: ` - + `, }) -export class AnnotatedAction {} +export class AnnotatedAction { + handle: StetHandle | null = null; + readonly onHandle = (next: StetHandle | null) => { this.handle = next; }; +} diff --git a/agent/templates/angular/highlight.ts b/agent/templates/angular/highlight.ts index df012a0..79e91bb 100644 --- a/agent/templates/angular/highlight.ts +++ b/agent/templates/angular/highlight.ts @@ -1,5 +1,6 @@ // Adapt these sample elements to existing source; retain their semantics and handlers. import { Component } from "@angular/core"; +import type { StetHandle } from "@funsaized/stet"; import { StetHighlightDirective } from "@funsaized/stet/angular"; // Add @import "@funsaized/stet/style.css"; to the application's global stylesheet. @Component({ @@ -7,7 +8,10 @@ import { StetHighlightDirective } from "@funsaized/stet/angular"; standalone: true, imports: [StetHighlightDirective], template: ` - + `, }) -export class AnnotatedAction {} +export class AnnotatedAction { + handle: StetHandle | null = null; + readonly onHandle = (next: StetHandle | null) => { this.handle = next; }; +} diff --git a/agent/templates/angular/lifecycle.ts b/agent/templates/angular/lifecycle.ts index 1d4ec95..2067401 100644 --- a/agent/templates/angular/lifecycle.ts +++ b/agent/templates/angular/lifecycle.ts @@ -1,6 +1,6 @@ // Adapt to existing controls; annotation state never controls their presence. import { Component, ElementRef, afterRenderEffect, input, viewChild } from "@angular/core"; -import { arrow, circle, sticky, type StetHandle } from "@funsaized/stet"; +import { arrow, box, circle, sticky, type StetHandle } from "@funsaized/stet"; function attachMarks(target: Element, destination: Element | null | undefined, enabled: boolean) { const handles: StetHandle[] = []; const destroy = () => { for (const handle of handles.splice(0).reverse()) handle.destroy(); }; @@ -13,6 +13,19 @@ function attachMarks(target: Element, destination: Element | null | undefined, e return destroy; } catch (error) { destroy(); throw error; } } +// Start hidden and animate on show so host code sequences reveal and replay. +export function attachMotionBox(target: Element) { + return box(target, { seed: 45, visible: false, animate: true }); +} +// Reduced motion settles immediately through the runtime; framework cleanup and +// handle.destroy() cancel any active show/replay work. +export async function runMotionSequence(handle: StetHandle | null) { + if (!handle) return; + handle.hide(); + if ((await handle.show()).status === "cancelled") return; + if ((await handle.replay()).status === "cancelled") return; + handle.hide(); +} // Add @import "@funsaized/stet/style.css"; to the application global stylesheet. @Component({ diff --git a/agent/templates/angular/mark.ts b/agent/templates/angular/mark.ts index 654f377..379340c 100644 --- a/agent/templates/angular/mark.ts +++ b/agent/templates/angular/mark.ts @@ -1,5 +1,6 @@ // Adapt these sample elements to existing source; retain their semantics and handlers. import { Component } from "@angular/core"; +import type { StetHandle } from "@funsaized/stet"; import { StetMarkDirective } from "@funsaized/stet/angular"; // Add @import "@funsaized/stet/style.css"; to the application's global stylesheet. @Component({ @@ -7,7 +8,10 @@ import { StetMarkDirective } from "@funsaized/stet/angular"; standalone: true, imports: [StetMarkDirective], template: ` - + `, }) -export class AnnotatedAction {} +export class AnnotatedAction { + handle: StetHandle | null = null; + readonly onHandle = (next: StetHandle | null) => { this.handle = next; }; +} diff --git a/agent/templates/angular/sticky.ts b/agent/templates/angular/sticky.ts index 6607b3d..6442458 100644 --- a/agent/templates/angular/sticky.ts +++ b/agent/templates/angular/sticky.ts @@ -1,5 +1,6 @@ // Adapt these sample elements to existing source; retain their semantics and handlers. import { Component } from "@angular/core"; +import type { StetHandle } from "@funsaized/stet"; import { StetStickyDirective } from "@funsaized/stet/angular"; // Add @import "@funsaized/stet/style.css"; to the application's global stylesheet. @Component({ @@ -7,7 +8,10 @@ import { StetStickyDirective } from "@funsaized/stet/angular"; standalone: true, imports: [StetStickyDirective], template: ` - + `, }) -export class AnnotatedAction {} +export class AnnotatedAction { + handle: StetHandle | null = null; + readonly onHandle = (next: StetHandle | null) => { this.handle = next; }; +} diff --git a/agent/templates/angular/underline.ts b/agent/templates/angular/underline.ts index 994f3bc..b62a0fc 100644 --- a/agent/templates/angular/underline.ts +++ b/agent/templates/angular/underline.ts @@ -1,5 +1,6 @@ // Adapt these sample elements to existing source; retain their semantics and handlers. import { Component } from "@angular/core"; +import type { StetHandle } from "@funsaized/stet"; import { StetUnderlineDirective } from "@funsaized/stet/angular"; // Add @import "@funsaized/stet/style.css"; to the application's global stylesheet. @Component({ @@ -7,7 +8,10 @@ import { StetUnderlineDirective } from "@funsaized/stet/angular"; standalone: true, imports: [StetUnderlineDirective], template: ` - + `, }) -export class AnnotatedAction {} +export class AnnotatedAction { + handle: StetHandle | null = null; + readonly onHandle = (next: StetHandle | null) => { this.handle = next; }; +} diff --git a/agent/templates/react/arrow.tsx b/agent/templates/react/arrow.tsx index c6ccd5c..2d9c149 100644 --- a/agent/templates/react/arrow.tsx +++ b/agent/templates/react/arrow.tsx @@ -1,15 +1,18 @@ "use client"; // Adapt these sample elements to existing source; retain their semantics and handlers. -import { useRef } from "react"; +import { useCallback, useRef } from "react"; import { Arrow } from "@funsaized/stet/react"; +import type { StetHandle } from "@funsaized/stet"; import "@funsaized/stet/style.css"; export function AnnotatedAction({ enabled = true }: { enabled?: boolean }) { const target = useRef(null); const destination = useRef(null); + const handle = useRef(null); + const onHandle = useCallback((next: StetHandle | null) => { handle.current = next; }, []); return <>

Consequences of this action

- {enabled && } + {enabled && } ; } diff --git a/agent/templates/react/box.tsx b/agent/templates/react/box.tsx new file mode 100644 index 0000000..36ae576 --- /dev/null +++ b/agent/templates/react/box.tsx @@ -0,0 +1,16 @@ +"use client"; +// Adapt these sample elements to existing source; retain their semantics and handlers. +import { useCallback, useRef } from "react"; +import { Box } from "@funsaized/stet/react"; +import type { StetHandle } from "@funsaized/stet"; +import "@funsaized/stet/style.css"; + +export function AnnotatedAction({ enabled = true }: { enabled?: boolean }) { + const target = useRef(null); + const handle = useRef(null); + const onHandle = useCallback((next: StetHandle | null) => { handle.current = next; }, []); + return <> + + {enabled && } + ; +} diff --git a/agent/templates/react/circle.tsx b/agent/templates/react/circle.tsx index 8bf4059..d0947a2 100644 --- a/agent/templates/react/circle.tsx +++ b/agent/templates/react/circle.tsx @@ -1,13 +1,16 @@ "use client"; // Adapt these sample elements to existing source; retain their semantics and handlers. -import { useRef } from "react"; +import { useCallback, useRef } from "react"; import { Circle } from "@funsaized/stet/react"; +import type { StetHandle } from "@funsaized/stet"; import "@funsaized/stet/style.css"; export function AnnotatedAction({ enabled = true }: { enabled?: boolean }) { const target = useRef(null); + const handle = useRef(null); + const onHandle = useCallback((next: StetHandle | null) => { handle.current = next; }, []); return <> - {enabled && } + {enabled && } ; } diff --git a/agent/templates/react/highlight.tsx b/agent/templates/react/highlight.tsx index 312a79c..a40b7bc 100644 --- a/agent/templates/react/highlight.tsx +++ b/agent/templates/react/highlight.tsx @@ -1,13 +1,16 @@ "use client"; // Adapt these sample elements to existing source; retain their semantics and handlers. -import { useRef } from "react"; +import { useCallback, useRef } from "react"; import { Highlight } from "@funsaized/stet/react"; +import type { StetHandle } from "@funsaized/stet"; import "@funsaized/stet/style.css"; export function AnnotatedAction({ enabled = true }: { enabled?: boolean }) { const target = useRef(null); + const handle = useRef(null); + const onHandle = useCallback((next: StetHandle | null) => { handle.current = next; }, []); return <> - {enabled && } + {enabled && } ; } diff --git a/agent/templates/react/lifecycle.tsx b/agent/templates/react/lifecycle.tsx index ff18df9..38cba1a 100644 --- a/agent/templates/react/lifecycle.tsx +++ b/agent/templates/react/lifecycle.tsx @@ -1,7 +1,7 @@ "use client"; // Adapt to existing controls; annotation state never controls their presence. import { useEffect, useRef } from "react"; -import { arrow, circle, sticky, type StetHandle } from "@funsaized/stet"; +import { arrow, box, circle, sticky, type StetHandle } from "@funsaized/stet"; function attachMarks(target: Element, destination: Element | null | undefined, enabled: boolean) { const handles: StetHandle[] = []; const destroy = () => { for (const handle of handles.splice(0).reverse()) handle.destroy(); }; @@ -14,6 +14,19 @@ function attachMarks(target: Element, destination: Element | null | undefined, e return destroy; } catch (error) { destroy(); throw error; } } +// Start hidden and animate on show so host code sequences reveal and replay. +export function attachMotionBox(target: Element) { + return box(target, { seed: 45, visible: false, animate: true }); +} +// Reduced motion settles immediately through the runtime; framework cleanup and +// handle.destroy() cancel any active show/replay work. +export async function runMotionSequence(handle: StetHandle | null) { + if (!handle) return; + handle.hide(); + if ((await handle.show()).status === "cancelled") return; + if ((await handle.replay()).status === "cancelled") return; + handle.hide(); +} import "@funsaized/stet/style.css"; export function AnnotatedAction({ enabled = true, destination = 0 }: { enabled?: boolean; destination?: number }) { diff --git a/agent/templates/react/mark.tsx b/agent/templates/react/mark.tsx index 6547d16..228e389 100644 --- a/agent/templates/react/mark.tsx +++ b/agent/templates/react/mark.tsx @@ -1,13 +1,16 @@ "use client"; // Adapt these sample elements to existing source; retain their semantics and handlers. -import { useRef } from "react"; +import { useCallback, useRef } from "react"; import { Mark } from "@funsaized/stet/react"; +import type { StetHandle } from "@funsaized/stet"; import "@funsaized/stet/style.css"; export function AnnotatedAction({ enabled = true }: { enabled?: boolean }) { const target = useRef(null); + const handle = useRef(null); + const onHandle = useCallback((next: StetHandle | null) => { handle.current = next; }, []); return <> - {enabled && } + {enabled && } ; } diff --git a/agent/templates/react/sticky.tsx b/agent/templates/react/sticky.tsx index cf3d202..7837cc9 100644 --- a/agent/templates/react/sticky.tsx +++ b/agent/templates/react/sticky.tsx @@ -1,13 +1,16 @@ "use client"; // Adapt these sample elements to existing source; retain their semantics and handlers. -import { useRef } from "react"; +import { useCallback, useRef } from "react"; import { Sticky } from "@funsaized/stet/react"; +import type { StetHandle } from "@funsaized/stet"; import "@funsaized/stet/style.css"; export function AnnotatedAction({ enabled = true }: { enabled?: boolean }) { const target = useRef(null); + const handle = useRef(null); + const onHandle = useCallback((next: StetHandle | null) => { handle.current = next; }, []); return <> - {enabled && } + {enabled && } ; } diff --git a/agent/templates/react/underline.tsx b/agent/templates/react/underline.tsx index b326d0e..1add207 100644 --- a/agent/templates/react/underline.tsx +++ b/agent/templates/react/underline.tsx @@ -1,13 +1,16 @@ "use client"; // Adapt these sample elements to existing source; retain their semantics and handlers. -import { useRef } from "react"; +import { useCallback, useRef } from "react"; import { Underline } from "@funsaized/stet/react"; +import type { StetHandle } from "@funsaized/stet"; import "@funsaized/stet/style.css"; export function AnnotatedAction({ enabled = true }: { enabled?: boolean }) { const target = useRef(null); + const handle = useRef(null); + const onHandle = useCallback((next: StetHandle | null) => { handle.current = next; }, []); return <> - {enabled && } + {enabled && } ; } diff --git a/agent/templates/svelte/arrow.svelte b/agent/templates/svelte/arrow.svelte index d38c2cb..9f6cb07 100644 --- a/agent/templates/svelte/arrow.svelte +++ b/agent/templates/svelte/arrow.svelte @@ -1,6 +1,6 @@ + + diff --git a/agent/templates/svelte/circle.svelte b/agent/templates/svelte/circle.svelte index 2b94d68..4d99b3f 100644 --- a/agent/templates/svelte/circle.svelte +++ b/agent/templates/svelte/circle.svelte @@ -1,7 +1,10 @@ - + diff --git a/agent/templates/svelte/highlight.svelte b/agent/templates/svelte/highlight.svelte index 13c7786..f140177 100644 --- a/agent/templates/svelte/highlight.svelte +++ b/agent/templates/svelte/highlight.svelte @@ -1,7 +1,10 @@ - + diff --git a/agent/templates/svelte/lifecycle.svelte b/agent/templates/svelte/lifecycle.svelte index 53d76bf..d2a839a 100644 --- a/agent/templates/svelte/lifecycle.svelte +++ b/agent/templates/svelte/lifecycle.svelte @@ -1,6 +1,6 @@ - + diff --git a/agent/templates/svelte/sticky.svelte b/agent/templates/svelte/sticky.svelte index cdf1ec3..f4e4835 100644 --- a/agent/templates/svelte/sticky.svelte +++ b/agent/templates/svelte/sticky.svelte @@ -1,7 +1,10 @@ - + diff --git a/agent/templates/svelte/underline.svelte b/agent/templates/svelte/underline.svelte index d288677..ef73dfe 100644 --- a/agent/templates/svelte/underline.svelte +++ b/agent/templates/svelte/underline.svelte @@ -1,7 +1,10 @@ - + diff --git a/agent/templates/vanilla/box.ts b/agent/templates/vanilla/box.ts new file mode 100644 index 0000000..2e995bf --- /dev/null +++ b/agent/templates/vanilla/box.ts @@ -0,0 +1,9 @@ +// Adapt these sample elements to existing source; retain their semantics and handlers. +import { box } from "@funsaized/stet"; +import "@funsaized/stet/style.css"; + +// Call after DOM mount with resolved, unique Elements. Call cleanup before removal. +export function annotate(target: Element) { + const handle = box(target, {"seed":42,"description":"Review this action before continuing."}); + return { refresh: () => handle.refresh(), destroy: () => handle.destroy() }; +} diff --git a/agent/templates/vanilla/lifecycle.ts b/agent/templates/vanilla/lifecycle.ts index edfe376..2a3a53a 100644 --- a/agent/templates/vanilla/lifecycle.ts +++ b/agent/templates/vanilla/lifecycle.ts @@ -1,5 +1,5 @@ // Adapt to existing controls; annotation state never controls their presence. -import { arrow, circle, sticky, type StetHandle } from "@funsaized/stet"; +import { arrow, box, circle, sticky, type StetHandle } from "@funsaized/stet"; function attachMarks(target: Element, destination: Element | null | undefined, enabled: boolean) { const handles: StetHandle[] = []; const destroy = () => { for (const handle of handles.splice(0).reverse()) handle.destroy(); }; @@ -12,6 +12,19 @@ function attachMarks(target: Element, destination: Element | null | undefined, e return destroy; } catch (error) { destroy(); throw error; } } +// Start hidden and animate on show so host code sequences reveal and replay. +export function attachMotionBox(target: Element) { + return box(target, { seed: 45, visible: false, animate: true }); +} +// Reduced motion settles immediately through the runtime; framework cleanup and +// handle.destroy() cancel any active show/replay work. +export async function runMotionSequence(handle: StetHandle | null) { + if (!handle) return; + handle.hide(); + if ((await handle.show()).status === "cancelled") return; + if ((await handle.replay()).status === "cancelled") return; + handle.hide(); +} import "@funsaized/stet/style.css"; // Call update after the application mounts/replaces/removes its destination. diff --git a/agent/templates/vue/arrow.vue b/agent/templates/vue/arrow.vue index 174b696..369eb46 100644 --- a/agent/templates/vue/arrow.vue +++ b/agent/templates/vue/arrow.vue @@ -1,7 +1,5 @@ - + + + diff --git a/agent/templates/vue/circle.vue b/agent/templates/vue/circle.vue index 820c8d7..db97e20 100644 --- a/agent/templates/vue/circle.vue +++ b/agent/templates/vue/circle.vue @@ -1,9 +1,13 @@ diff --git a/agent/templates/vue/highlight.vue b/agent/templates/vue/highlight.vue index dff5d22..dee1515 100644 --- a/agent/templates/vue/highlight.vue +++ b/agent/templates/vue/highlight.vue @@ -1,9 +1,13 @@ diff --git a/agent/templates/vue/lifecycle.vue b/agent/templates/vue/lifecycle.vue index bd16e45..fbf21ec 100644 --- a/agent/templates/vue/lifecycle.vue +++ b/agent/templates/vue/lifecycle.vue @@ -1,7 +1,5 @@ - + diff --git a/agent/templates/vue/sticky.vue b/agent/templates/vue/sticky.vue index a502dc5..aae66a3 100644 --- a/agent/templates/vue/sticky.vue +++ b/agent/templates/vue/sticky.vue @@ -1,9 +1,13 @@ diff --git a/agent/templates/vue/underline.vue b/agent/templates/vue/underline.vue index 46be90e..e2ccba9 100644 --- a/agent/templates/vue/underline.vue +++ b/agent/templates/vue/underline.vue @@ -1,9 +1,13 @@ diff --git a/agent/validate.generated.mjs b/agent/validate.generated.mjs index 790db33..d7f7e79 100644 --- a/agent/validate.generated.mjs +++ b/agent/validate.generated.mjs @@ -1,2 +1,2 @@ // Generated by Ajv (MIT); see LICENSE-ajv.txt. Do not edit. -"use strict";export const validate = validate10;export default validate10;const schema11 = {"$schema":"http://json-schema.org/draft-07/schema#","title":"Stet Annotation Plan v1","type":"object","additionalProperties":false,"properties":{"version":{"type":"integer","const":1},"framework":{"type":"string","enum":["vanilla","react","vue","svelte","angular"]},"intent":{"type":"string","pattern":"\\S"},"annotations":{"type":"array","minItems":1,"items":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"circle","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":1,"maxItems":1},"options":{"type":"object","additionalProperties":false,"properties":{"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"visible":{"type":"boolean"},"animate":{"type":"boolean"},"animationDuration":{"type":"number","minimum":0},"animationDelay":{"type":"number","minimum":0},"description":{"type":"string","pattern":"\\S"}},"required":[]}},"required":["id","primitive","targets"]},{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"underline","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":1,"maxItems":1},"options":{"type":"object","additionalProperties":false,"properties":{"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"visible":{"type":"boolean"},"animate":{"type":"boolean"},"animationDuration":{"type":"number","minimum":0},"animationDelay":{"type":"number","minimum":0},"description":{"type":"string","pattern":"\\S"}},"required":[]}},"required":["id","primitive","targets"]},{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"highlight","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":1,"maxItems":1},"options":{"type":"object","additionalProperties":false,"properties":{"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"visible":{"type":"boolean"},"animate":{"type":"boolean"},"animationDuration":{"type":"number","minimum":0},"animationDelay":{"type":"number","minimum":0},"description":{"type":"string","pattern":"\\S"}},"required":[],"allOf":[{"properties":{"animate":{"not":{"const":true}}}},{"if":{"anyOf":[{"required":["animationDuration"],"properties":{"animationDuration":true}},{"required":["animationDelay"],"properties":{"animationDelay":true}}]},"then":{"properties":{"animate":{"const":false}},"required":["animate"]}}]}},"required":["id","primitive","targets"]},{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"arrow","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":2,"maxItems":2},"options":{"type":"object","additionalProperties":false,"properties":{"label":{"type":"string","pattern":"\\S"},"curvature":{"type":"number"},"labelOffsetX":{"type":"number"},"labelOffsetY":{"type":"number"},"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"visible":{"type":"boolean"},"animate":{"type":"boolean"},"animationDuration":{"type":"number","minimum":0},"animationDelay":{"type":"number","minimum":0},"description":{"type":"string","pattern":"\\S"}},"required":[],"allOf":[{"properties":{"animate":{"not":{"const":true}}}},{"if":{"anyOf":[{"required":["animationDuration"],"properties":{"animationDuration":true}},{"required":["animationDelay"],"properties":{"animationDelay":true}}]},"then":{"properties":{"animate":{"const":false}},"required":["animate"]}}]}},"required":["id","primitive","targets"]},{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"sticky","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":1,"maxItems":1},"options":{"type":"object","additionalProperties":false,"properties":{"text":{"type":"string","pattern":"\\S"},"side":{"type":"string","enum":["auto","top","right","bottom","left"]},"offsetX":{"type":"number"},"offsetY":{"type":"number"},"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"visible":{"type":"boolean"},"animate":{"type":"boolean"},"animationDuration":{"type":"number","minimum":0},"animationDelay":{"type":"number","minimum":0},"description":{"type":"string","pattern":"\\S"}},"required":["text"],"allOf":[{"properties":{"animate":{"not":{"const":true}}}},{"if":{"anyOf":[{"required":["animationDuration"],"properties":{"animationDuration":true}},{"required":["animationDelay"],"properties":{"animationDelay":true}}]},"then":{"properties":{"animate":{"const":false}},"required":["animate"]}}]}},"required":["id","primitive","targets","options"]},{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"mark","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":1,"maxItems":1},"options":{"type":"object","additionalProperties":false,"properties":{"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"visible":{"type":"boolean"},"animate":{"type":"boolean"},"animationDuration":{"type":"number","minimum":0},"animationDelay":{"type":"number","minimum":0},"description":{"type":"string","pattern":"\\S"}},"required":[],"allOf":[{"properties":{"animate":{"not":{"const":true}}}},{"if":{"anyOf":[{"required":["animationDuration"],"properties":{"animationDuration":true}},{"required":["animationDelay"],"properties":{"animationDelay":true}}]},"then":{"properties":{"animate":{"const":false}},"required":["animate"]}}]},"kind":{"type":"string","enum":["right","wrong"]}},"required":["id","primitive","targets","kind"]}]}}},"required":["version","framework","intent","annotations"]};const pattern0 = new RegExp("\\S", "u");const pattern1 = new RegExp("^[a-zA-Z0-9][a-zA-Z0-9._-]*$", "u");const func2 = Object.prototype.hasOwnProperty;function validate10(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;if(data && typeof data == "object" && !Array.isArray(data)){if(data.version === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "version"},message:"must have required property '"+"version"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.framework === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "framework"},message:"must have required property '"+"framework"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.intent === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "intent"},message:"must have required property '"+"intent"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.annotations === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "annotations"},message:"must have required property '"+"annotations"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}for(const key0 in data){if(!((((key0 === "version") || (key0 === "framework")) || (key0 === "intent")) || (key0 === "annotations"))){const err4 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}if(data.version !== undefined){let data0 = data.version;if(!(((typeof data0 == "number") && (!(data0 % 1) && !isNaN(data0))) && (isFinite(data0)))){const err5 = {instancePath:instancePath+"/version",schemaPath:"#/properties/version/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(1 !== data0){const err6 = {instancePath:instancePath+"/version",schemaPath:"#/properties/version/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.framework !== undefined){let data1 = data.framework;if(typeof data1 !== "string"){const err7 = {instancePath:instancePath+"/framework",schemaPath:"#/properties/framework/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(!(((((data1 === "vanilla") || (data1 === "react")) || (data1 === "vue")) || (data1 === "svelte")) || (data1 === "angular"))){const err8 = {instancePath:instancePath+"/framework",schemaPath:"#/properties/framework/enum",keyword:"enum",params:{allowedValues: schema11.properties.framework.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.intent !== undefined){let data2 = data.intent;if(typeof data2 === "string"){if(!pattern0.test(data2)){const err9 = {instancePath:instancePath+"/intent",schemaPath:"#/properties/intent/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/intent",schemaPath:"#/properties/intent/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.annotations !== undefined){let data3 = data.annotations;if(Array.isArray(data3)){if(data3.length < 1){const err11 = {instancePath:instancePath+"/annotations",schemaPath:"#/properties/annotations/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}const len0 = data3.length;for(let i0=0; i0 1){const err20 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/0/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(data7.length < 1){const err21 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/0/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}const len1 = data7.length;for(let i1=0; i1=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}}else {const err57 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}}if(data16.animationDelay !== undefined){let data28 = data16.animationDelay;if((typeof data28 == "number") && (isFinite(data28))){if(data28 < 0 || isNaN(data28)){const err58 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}}else {const err59 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}if(data16.description !== undefined){let data29 = data16.description;if(typeof data29 === "string"){if(!pattern0.test(data29)){const err60 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}}else {const err61 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}}}else {const err62 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}}}else {const err63 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/0/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}var _valid0 = _errs12 === errors;if(_valid0){valid3 = true;passing0 = 0;}const _errs70 = errors;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err64 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/1/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}if(data4.primitive === undefined){const err65 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/1/required",keyword:"required",params:{missingProperty: "primitive"},message:"must have required property '"+"primitive"+"'"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}if(data4.targets === undefined){const err66 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/1/required",keyword:"required",params:{missingProperty: "targets"},message:"must have required property '"+"targets"+"'"};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}for(const key4 in data4){if(!((((key4 === "id") || (key4 === "primitive")) || (key4 === "targets")) || (key4 === "options"))){const err67 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/1/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key4},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}}if(data4.id !== undefined){let data30 = data4.id;if(typeof data30 === "string"){if(!pattern1.test(data30)){const err68 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/1/properties/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},message:"must match pattern \""+"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"+"\""};if(vErrors === null){vErrors = [err68];}else {vErrors.push(err68);}errors++;}}else {const err69 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/1/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}}if(data4.primitive !== undefined){let data31 = data4.primitive;if(typeof data31 !== "string"){const err70 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/1/properties/primitive/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}if("underline" !== data31){const err71 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/1/properties/primitive/const",keyword:"const",params:{allowedValue: "underline"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}}if(data4.targets !== undefined){let data32 = data4.targets;if(Array.isArray(data32)){if(data32.length > 1){const err72 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/1/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}if(data32.length < 1){const err73 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/1/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err73];}else {vErrors.push(err73);}errors++;}const len2 = data32.length;for(let i2=0; i2=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err108];}else {vErrors.push(err108);}errors++;}}else {const err109 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err109];}else {vErrors.push(err109);}errors++;}}if(data41.animationDelay !== undefined){let data53 = data41.animationDelay;if((typeof data53 == "number") && (isFinite(data53))){if(data53 < 0 || isNaN(data53)){const err110 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err110];}else {vErrors.push(err110);}errors++;}}else {const err111 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err111];}else {vErrors.push(err111);}errors++;}}if(data41.description !== undefined){let data54 = data41.description;if(typeof data54 === "string"){if(!pattern0.test(data54)){const err112 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err112];}else {vErrors.push(err112);}errors++;}}else {const err113 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err113];}else {vErrors.push(err113);}errors++;}}}else {const err114 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err114];}else {vErrors.push(err114);}errors++;}}}else {const err115 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err115];}else {vErrors.push(err115);}errors++;}var _valid0 = _errs70 === errors;if(_valid0 && valid3){valid3 = false;passing0 = [passing0, 1];}else {if(_valid0){valid3 = true;passing0 = 1;}const _errs128 = errors;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err116 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/2/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err116];}else {vErrors.push(err116);}errors++;}if(data4.primitive === undefined){const err117 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/2/required",keyword:"required",params:{missingProperty: "primitive"},message:"must have required property '"+"primitive"+"'"};if(vErrors === null){vErrors = [err117];}else {vErrors.push(err117);}errors++;}if(data4.targets === undefined){const err118 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/2/required",keyword:"required",params:{missingProperty: "targets"},message:"must have required property '"+"targets"+"'"};if(vErrors === null){vErrors = [err118];}else {vErrors.push(err118);}errors++;}for(const key7 in data4){if(!((((key7 === "id") || (key7 === "primitive")) || (key7 === "targets")) || (key7 === "options"))){const err119 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/2/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key7},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err119];}else {vErrors.push(err119);}errors++;}}if(data4.id !== undefined){let data55 = data4.id;if(typeof data55 === "string"){if(!pattern1.test(data55)){const err120 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/2/properties/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},message:"must match pattern \""+"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"+"\""};if(vErrors === null){vErrors = [err120];}else {vErrors.push(err120);}errors++;}}else {const err121 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/2/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err121];}else {vErrors.push(err121);}errors++;}}if(data4.primitive !== undefined){let data56 = data4.primitive;if(typeof data56 !== "string"){const err122 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/2/properties/primitive/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err122];}else {vErrors.push(err122);}errors++;}if("highlight" !== data56){const err123 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/2/properties/primitive/const",keyword:"const",params:{allowedValue: "highlight"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err123];}else {vErrors.push(err123);}errors++;}}if(data4.targets !== undefined){let data57 = data4.targets;if(Array.isArray(data57)){if(data57.length > 1){const err124 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/2/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err124];}else {vErrors.push(err124);}errors++;}if(data57.length < 1){const err125 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/2/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err125];}else {vErrors.push(err125);}errors++;}const len3 = data57.length;for(let i3=0; i3=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err168];}else {vErrors.push(err168);}errors++;}}else {const err169 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err169];}else {vErrors.push(err169);}errors++;}}if(data66.animationDelay !== undefined){let data80 = data66.animationDelay;if((typeof data80 == "number") && (isFinite(data80))){if(data80 < 0 || isNaN(data80)){const err170 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err170];}else {vErrors.push(err170);}errors++;}}else {const err171 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err171];}else {vErrors.push(err171);}errors++;}}if(data66.description !== undefined){let data81 = data66.description;if(typeof data81 === "string"){if(!pattern0.test(data81)){const err172 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err172];}else {vErrors.push(err172);}errors++;}}else {const err173 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err173];}else {vErrors.push(err173);}errors++;}}}else {const err174 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err174];}else {vErrors.push(err174);}errors++;}}}else {const err175 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/2/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err175];}else {vErrors.push(err175);}errors++;}var _valid0 = _errs128 === errors;if(_valid0 && valid3){valid3 = false;passing0 = [passing0, 2];}else {if(_valid0){valid3 = true;passing0 = 2;}const _errs198 = errors;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err176 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/3/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err176];}else {vErrors.push(err176);}errors++;}if(data4.primitive === undefined){const err177 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/3/required",keyword:"required",params:{missingProperty: "primitive"},message:"must have required property '"+"primitive"+"'"};if(vErrors === null){vErrors = [err177];}else {vErrors.push(err177);}errors++;}if(data4.targets === undefined){const err178 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/3/required",keyword:"required",params:{missingProperty: "targets"},message:"must have required property '"+"targets"+"'"};if(vErrors === null){vErrors = [err178];}else {vErrors.push(err178);}errors++;}for(const key10 in data4){if(!((((key10 === "id") || (key10 === "primitive")) || (key10 === "targets")) || (key10 === "options"))){const err179 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/3/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key10},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err179];}else {vErrors.push(err179);}errors++;}}if(data4.id !== undefined){let data82 = data4.id;if(typeof data82 === "string"){if(!pattern1.test(data82)){const err180 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/3/properties/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},message:"must match pattern \""+"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"+"\""};if(vErrors === null){vErrors = [err180];}else {vErrors.push(err180);}errors++;}}else {const err181 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/3/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err181];}else {vErrors.push(err181);}errors++;}}if(data4.primitive !== undefined){let data83 = data4.primitive;if(typeof data83 !== "string"){const err182 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/3/properties/primitive/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err182];}else {vErrors.push(err182);}errors++;}if("arrow" !== data83){const err183 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/3/properties/primitive/const",keyword:"const",params:{allowedValue: "arrow"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err183];}else {vErrors.push(err183);}errors++;}}if(data4.targets !== undefined){let data84 = data4.targets;if(Array.isArray(data84)){if(data84.length > 2){const err184 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/3/properties/targets/maxItems",keyword:"maxItems",params:{limit: 2},message:"must NOT have more than 2 items"};if(vErrors === null){vErrors = [err184];}else {vErrors.push(err184);}errors++;}if(data84.length < 2){const err185 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/3/properties/targets/minItems",keyword:"minItems",params:{limit: 2},message:"must NOT have fewer than 2 items"};if(vErrors === null){vErrors = [err185];}else {vErrors.push(err185);}errors++;}const len4 = data84.length;for(let i4=0; i4=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err233];}else {vErrors.push(err233);}errors++;}}else {const err234 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err234];}else {vErrors.push(err234);}errors++;}}if(data93.animationDelay !== undefined){let data111 = data93.animationDelay;if((typeof data111 == "number") && (isFinite(data111))){if(data111 < 0 || isNaN(data111)){const err235 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err235];}else {vErrors.push(err235);}errors++;}}else {const err236 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err236];}else {vErrors.push(err236);}errors++;}}if(data93.description !== undefined){let data112 = data93.description;if(typeof data112 === "string"){if(!pattern0.test(data112)){const err237 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err237];}else {vErrors.push(err237);}errors++;}}else {const err238 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err238];}else {vErrors.push(err238);}errors++;}}}else {const err239 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err239];}else {vErrors.push(err239);}errors++;}}}else {const err240 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/3/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err240];}else {vErrors.push(err240);}errors++;}var _valid0 = _errs198 === errors;if(_valid0 && valid3){valid3 = false;passing0 = [passing0, 3];}else {if(_valid0){valid3 = true;passing0 = 3;}const _errs276 = errors;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err241 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err241];}else {vErrors.push(err241);}errors++;}if(data4.primitive === undefined){const err242 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/required",keyword:"required",params:{missingProperty: "primitive"},message:"must have required property '"+"primitive"+"'"};if(vErrors === null){vErrors = [err242];}else {vErrors.push(err242);}errors++;}if(data4.targets === undefined){const err243 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/required",keyword:"required",params:{missingProperty: "targets"},message:"must have required property '"+"targets"+"'"};if(vErrors === null){vErrors = [err243];}else {vErrors.push(err243);}errors++;}if(data4.options === undefined){const err244 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/required",keyword:"required",params:{missingProperty: "options"},message:"must have required property '"+"options"+"'"};if(vErrors === null){vErrors = [err244];}else {vErrors.push(err244);}errors++;}for(const key13 in data4){if(!((((key13 === "id") || (key13 === "primitive")) || (key13 === "targets")) || (key13 === "options"))){const err245 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key13},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err245];}else {vErrors.push(err245);}errors++;}}if(data4.id !== undefined){let data113 = data4.id;if(typeof data113 === "string"){if(!pattern1.test(data113)){const err246 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/4/properties/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},message:"must match pattern \""+"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"+"\""};if(vErrors === null){vErrors = [err246];}else {vErrors.push(err246);}errors++;}}else {const err247 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/4/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err247];}else {vErrors.push(err247);}errors++;}}if(data4.primitive !== undefined){let data114 = data4.primitive;if(typeof data114 !== "string"){const err248 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/4/properties/primitive/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err248];}else {vErrors.push(err248);}errors++;}if("sticky" !== data114){const err249 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/4/properties/primitive/const",keyword:"const",params:{allowedValue: "sticky"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err249];}else {vErrors.push(err249);}errors++;}}if(data4.targets !== undefined){let data115 = data4.targets;if(Array.isArray(data115)){if(data115.length > 1){const err250 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/4/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err250];}else {vErrors.push(err250);}errors++;}if(data115.length < 1){const err251 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/4/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err251];}else {vErrors.push(err251);}errors++;}const len5 = data115.length;for(let i5=0; i5=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err301];}else {vErrors.push(err301);}errors++;}}else {const err302 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err302];}else {vErrors.push(err302);}errors++;}}if(data124.animationDelay !== undefined){let data142 = data124.animationDelay;if((typeof data142 == "number") && (isFinite(data142))){if(data142 < 0 || isNaN(data142)){const err303 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err303];}else {vErrors.push(err303);}errors++;}}else {const err304 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err304];}else {vErrors.push(err304);}errors++;}}if(data124.description !== undefined){let data143 = data124.description;if(typeof data143 === "string"){if(!pattern0.test(data143)){const err305 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err305];}else {vErrors.push(err305);}errors++;}}else {const err306 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err306];}else {vErrors.push(err306);}errors++;}}}else {const err307 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err307];}else {vErrors.push(err307);}errors++;}}}else {const err308 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err308];}else {vErrors.push(err308);}errors++;}var _valid0 = _errs276 === errors;if(_valid0 && valid3){valid3 = false;passing0 = [passing0, 4];}else {if(_valid0){valid3 = true;passing0 = 4;}const _errs354 = errors;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err309 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err309];}else {vErrors.push(err309);}errors++;}if(data4.primitive === undefined){const err310 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/required",keyword:"required",params:{missingProperty: "primitive"},message:"must have required property '"+"primitive"+"'"};if(vErrors === null){vErrors = [err310];}else {vErrors.push(err310);}errors++;}if(data4.targets === undefined){const err311 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/required",keyword:"required",params:{missingProperty: "targets"},message:"must have required property '"+"targets"+"'"};if(vErrors === null){vErrors = [err311];}else {vErrors.push(err311);}errors++;}if(data4.kind === undefined){const err312 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err312];}else {vErrors.push(err312);}errors++;}for(const key16 in data4){if(!(((((key16 === "id") || (key16 === "primitive")) || (key16 === "targets")) || (key16 === "options")) || (key16 === "kind"))){const err313 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key16},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err313];}else {vErrors.push(err313);}errors++;}}if(data4.id !== undefined){let data144 = data4.id;if(typeof data144 === "string"){if(!pattern1.test(data144)){const err314 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/5/properties/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},message:"must match pattern \""+"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"+"\""};if(vErrors === null){vErrors = [err314];}else {vErrors.push(err314);}errors++;}}else {const err315 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/5/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err315];}else {vErrors.push(err315);}errors++;}}if(data4.primitive !== undefined){let data145 = data4.primitive;if(typeof data145 !== "string"){const err316 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/5/properties/primitive/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err316];}else {vErrors.push(err316);}errors++;}if("mark" !== data145){const err317 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/5/properties/primitive/const",keyword:"const",params:{allowedValue: "mark"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err317];}else {vErrors.push(err317);}errors++;}}if(data4.targets !== undefined){let data146 = data4.targets;if(Array.isArray(data146)){if(data146.length > 1){const err318 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/5/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err318];}else {vErrors.push(err318);}errors++;}if(data146.length < 1){const err319 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/5/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err319];}else {vErrors.push(err319);}errors++;}const len6 = data146.length;for(let i6=0; i6=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err362];}else {vErrors.push(err362);}errors++;}}else {const err363 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err363];}else {vErrors.push(err363);}errors++;}}if(data155.animationDelay !== undefined){let data169 = data155.animationDelay;if((typeof data169 == "number") && (isFinite(data169))){if(data169 < 0 || isNaN(data169)){const err364 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err364];}else {vErrors.push(err364);}errors++;}}else {const err365 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err365];}else {vErrors.push(err365);}errors++;}}if(data155.description !== undefined){let data170 = data155.description;if(typeof data170 === "string"){if(!pattern0.test(data170)){const err366 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err366];}else {vErrors.push(err366);}errors++;}}else {const err367 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err367];}else {vErrors.push(err367);}errors++;}}}else {const err368 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err368];}else {vErrors.push(err368);}errors++;}}if(data4.kind !== undefined){let data171 = data4.kind;if(typeof data171 !== "string"){const err369 = {instancePath:instancePath+"/annotations/" + i0+"/kind",schemaPath:"#/properties/annotations/items/oneOf/5/properties/kind/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err369];}else {vErrors.push(err369);}errors++;}if(!((data171 === "right") || (data171 === "wrong"))){const err370 = {instancePath:instancePath+"/annotations/" + i0+"/kind",schemaPath:"#/properties/annotations/items/oneOf/5/properties/kind/enum",keyword:"enum",params:{allowedValues: schema11.properties.annotations.items.oneOf[5].properties.kind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err370];}else {vErrors.push(err370);}errors++;}}}else {const err371 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err371];}else {vErrors.push(err371);}errors++;}var _valid0 = _errs354 === errors;if(_valid0 && valid3){valid3 = false;passing0 = [passing0, 5];}else {if(_valid0){valid3 = true;passing0 = 5;}}}}}}if(!valid3){const err372 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err372];}else {vErrors.push(err372);}errors++;}else {errors = _errs11;if(vErrors !== null){if(_errs11){vErrors.length = _errs11;}else {vErrors = null;}}}}}else {const err373 = {instancePath:instancePath+"/annotations",schemaPath:"#/properties/annotations/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err373];}else {vErrors.push(err373);}errors++;}}}else {const err374 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err374];}else {vErrors.push(err374);}errors++;}validate10.errors = vErrors;return errors === 0;} +"use strict";export const validate = validate10;export default validate10;const schema11 = {"$schema":"http://json-schema.org/draft-07/schema#","title":"Stet Annotation Plan v1","type":"object","additionalProperties":false,"properties":{"version":{"type":"integer","const":1},"framework":{"type":"string","enum":["vanilla","react","vue","svelte","angular"]},"intent":{"type":"string","pattern":"\\S"},"annotations":{"type":"array","minItems":1,"items":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"box","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":1,"maxItems":1},"options":{"type":"object","additionalProperties":false,"properties":{"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"visible":{"type":"boolean"},"animate":{"type":"boolean"},"animationDuration":{"type":"number","minimum":0},"animationDelay":{"type":"number","minimum":0},"description":{"type":"string","pattern":"\\S"}},"required":[]}},"required":["id","primitive","targets"]},{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"circle","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":1,"maxItems":1},"options":{"type":"object","additionalProperties":false,"properties":{"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"visible":{"type":"boolean"},"animate":{"type":"boolean"},"animationDuration":{"type":"number","minimum":0},"animationDelay":{"type":"number","minimum":0},"description":{"type":"string","pattern":"\\S"}},"required":[]}},"required":["id","primitive","targets"]},{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"underline","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":1,"maxItems":1},"options":{"type":"object","additionalProperties":false,"properties":{"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"visible":{"type":"boolean"},"animate":{"type":"boolean"},"animationDuration":{"type":"number","minimum":0},"animationDelay":{"type":"number","minimum":0},"description":{"type":"string","pattern":"\\S"}},"required":[]}},"required":["id","primitive","targets"]},{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"highlight","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":1,"maxItems":1},"options":{"type":"object","additionalProperties":false,"properties":{"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"visible":{"type":"boolean"},"animate":{"type":"boolean"},"animationDuration":{"type":"number","minimum":0},"animationDelay":{"type":"number","minimum":0},"description":{"type":"string","pattern":"\\S"}},"required":[],"allOf":[{"properties":{"animate":{"not":{"const":true}}}},{"if":{"anyOf":[{"required":["animationDuration"],"properties":{"animationDuration":true}},{"required":["animationDelay"],"properties":{"animationDelay":true}}]},"then":{"properties":{"animate":{"const":false}},"required":["animate"]}}]}},"required":["id","primitive","targets"]},{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"arrow","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":2,"maxItems":2},"options":{"type":"object","additionalProperties":false,"properties":{"label":{"type":"string","pattern":"\\S"},"curvature":{"type":"number"},"labelOffsetX":{"type":"number"},"labelOffsetY":{"type":"number"},"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"visible":{"type":"boolean"},"animate":{"type":"boolean"},"animationDuration":{"type":"number","minimum":0},"animationDelay":{"type":"number","minimum":0},"description":{"type":"string","pattern":"\\S"}},"required":[],"allOf":[{"properties":{"animate":{"not":{"const":true}}}},{"if":{"anyOf":[{"required":["animationDuration"],"properties":{"animationDuration":true}},{"required":["animationDelay"],"properties":{"animationDelay":true}}]},"then":{"properties":{"animate":{"const":false}},"required":["animate"]}}]}},"required":["id","primitive","targets"]},{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"sticky","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":1,"maxItems":1},"options":{"type":"object","additionalProperties":false,"properties":{"text":{"type":"string","pattern":"\\S"},"side":{"type":"string","enum":["auto","top","right","bottom","left"]},"offsetX":{"type":"number"},"offsetY":{"type":"number"},"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"visible":{"type":"boolean"},"animate":{"type":"boolean"},"animationDuration":{"type":"number","minimum":0},"animationDelay":{"type":"number","minimum":0},"description":{"type":"string","pattern":"\\S"}},"required":["text"],"allOf":[{"properties":{"animate":{"not":{"const":true}}}},{"if":{"anyOf":[{"required":["animationDuration"],"properties":{"animationDuration":true}},{"required":["animationDelay"],"properties":{"animationDelay":true}}]},"then":{"properties":{"animate":{"const":false}},"required":["animate"]}}]}},"required":["id","primitive","targets","options"]},{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},"primitive":{"const":"mark","type":"string"},"targets":{"type":"array","items":{"type":"object","additionalProperties":false,"properties":{"strategy":{"enum":["ref","id","data-attribute","source","stet-attribute","css"],"type":"string"},"file":{"type":"string","pattern":"\\S"},"locator":{"type":"string","pattern":"\\S"},"description":{"type":"string","pattern":"\\S"},"rationale":{"type":"string","pattern":"\\S"}},"required":["strategy","file","locator","description"],"allOf":[{"if":{"properties":{"strategy":{"const":"css"}},"required":["strategy"]},"then":{"properties":{"rationale":{"type":"string","pattern":"\\S"}},"required":["rationale"]}}]},"minItems":1,"maxItems":1},"options":{"type":"object","additionalProperties":false,"properties":{"seed":{"type":"number"},"roughness":{"type":"number"},"boil":{"type":"number"},"stroke":{"type":"string"},"fill":{"type":"string"},"width":{"type":"number"},"resketchOnHover":{"type":"boolean"},"padding":{"type":"number"},"visible":{"type":"boolean"},"animate":{"type":"boolean"},"animationDuration":{"type":"number","minimum":0},"animationDelay":{"type":"number","minimum":0},"description":{"type":"string","pattern":"\\S"}},"required":[],"allOf":[{"properties":{"animate":{"not":{"const":true}}}},{"if":{"anyOf":[{"required":["animationDuration"],"properties":{"animationDuration":true}},{"required":["animationDelay"],"properties":{"animationDelay":true}}]},"then":{"properties":{"animate":{"const":false}},"required":["animate"]}}]},"kind":{"type":"string","enum":["right","wrong"]}},"required":["id","primitive","targets","kind"]}]}}},"required":["version","framework","intent","annotations"]};const pattern0 = new RegExp("\\S", "u");const pattern1 = new RegExp("^[a-zA-Z0-9][a-zA-Z0-9._-]*$", "u");const func2 = Object.prototype.hasOwnProperty;function validate10(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;if(data && typeof data == "object" && !Array.isArray(data)){if(data.version === undefined){const err0 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "version"},message:"must have required property '"+"version"+"'"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}if(data.framework === undefined){const err1 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "framework"},message:"must have required property '"+"framework"+"'"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(data.intent === undefined){const err2 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "intent"},message:"must have required property '"+"intent"+"'"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(data.annotations === undefined){const err3 = {instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: "annotations"},message:"must have required property '"+"annotations"+"'"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}for(const key0 in data){if(!((((key0 === "version") || (key0 === "framework")) || (key0 === "intent")) || (key0 === "annotations"))){const err4 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}if(data.version !== undefined){let data0 = data.version;if(!(((typeof data0 == "number") && (!(data0 % 1) && !isNaN(data0))) && (isFinite(data0)))){const err5 = {instancePath:instancePath+"/version",schemaPath:"#/properties/version/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(1 !== data0){const err6 = {instancePath:instancePath+"/version",schemaPath:"#/properties/version/const",keyword:"const",params:{allowedValue: 1},message:"must be equal to constant"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.framework !== undefined){let data1 = data.framework;if(typeof data1 !== "string"){const err7 = {instancePath:instancePath+"/framework",schemaPath:"#/properties/framework/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(!(((((data1 === "vanilla") || (data1 === "react")) || (data1 === "vue")) || (data1 === "svelte")) || (data1 === "angular"))){const err8 = {instancePath:instancePath+"/framework",schemaPath:"#/properties/framework/enum",keyword:"enum",params:{allowedValues: schema11.properties.framework.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.intent !== undefined){let data2 = data.intent;if(typeof data2 === "string"){if(!pattern0.test(data2)){const err9 = {instancePath:instancePath+"/intent",schemaPath:"#/properties/intent/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}else {const err10 = {instancePath:instancePath+"/intent",schemaPath:"#/properties/intent/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.annotations !== undefined){let data3 = data.annotations;if(Array.isArray(data3)){if(data3.length < 1){const err11 = {instancePath:instancePath+"/annotations",schemaPath:"#/properties/annotations/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}const len0 = data3.length;for(let i0=0; i0 1){const err20 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/0/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(data7.length < 1){const err21 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/0/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}const len1 = data7.length;for(let i1=0; i1=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err56];}else {vErrors.push(err56);}errors++;}}else {const err57 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err57];}else {vErrors.push(err57);}errors++;}}if(data16.animationDelay !== undefined){let data28 = data16.animationDelay;if((typeof data28 == "number") && (isFinite(data28))){if(data28 < 0 || isNaN(data28)){const err58 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err58];}else {vErrors.push(err58);}errors++;}}else {const err59 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err59];}else {vErrors.push(err59);}errors++;}}if(data16.description !== undefined){let data29 = data16.description;if(typeof data29 === "string"){if(!pattern0.test(data29)){const err60 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err60];}else {vErrors.push(err60);}errors++;}}else {const err61 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err61];}else {vErrors.push(err61);}errors++;}}}else {const err62 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/0/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err62];}else {vErrors.push(err62);}errors++;}}}else {const err63 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/0/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err63];}else {vErrors.push(err63);}errors++;}var _valid0 = _errs12 === errors;if(_valid0){valid3 = true;passing0 = 0;}const _errs70 = errors;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err64 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/1/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err64];}else {vErrors.push(err64);}errors++;}if(data4.primitive === undefined){const err65 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/1/required",keyword:"required",params:{missingProperty: "primitive"},message:"must have required property '"+"primitive"+"'"};if(vErrors === null){vErrors = [err65];}else {vErrors.push(err65);}errors++;}if(data4.targets === undefined){const err66 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/1/required",keyword:"required",params:{missingProperty: "targets"},message:"must have required property '"+"targets"+"'"};if(vErrors === null){vErrors = [err66];}else {vErrors.push(err66);}errors++;}for(const key4 in data4){if(!((((key4 === "id") || (key4 === "primitive")) || (key4 === "targets")) || (key4 === "options"))){const err67 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/1/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key4},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err67];}else {vErrors.push(err67);}errors++;}}if(data4.id !== undefined){let data30 = data4.id;if(typeof data30 === "string"){if(!pattern1.test(data30)){const err68 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/1/properties/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},message:"must match pattern \""+"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"+"\""};if(vErrors === null){vErrors = [err68];}else {vErrors.push(err68);}errors++;}}else {const err69 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/1/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err69];}else {vErrors.push(err69);}errors++;}}if(data4.primitive !== undefined){let data31 = data4.primitive;if(typeof data31 !== "string"){const err70 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/1/properties/primitive/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err70];}else {vErrors.push(err70);}errors++;}if("circle" !== data31){const err71 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/1/properties/primitive/const",keyword:"const",params:{allowedValue: "circle"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err71];}else {vErrors.push(err71);}errors++;}}if(data4.targets !== undefined){let data32 = data4.targets;if(Array.isArray(data32)){if(data32.length > 1){const err72 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/1/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err72];}else {vErrors.push(err72);}errors++;}if(data32.length < 1){const err73 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/1/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err73];}else {vErrors.push(err73);}errors++;}const len2 = data32.length;for(let i2=0; i2=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err108];}else {vErrors.push(err108);}errors++;}}else {const err109 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err109];}else {vErrors.push(err109);}errors++;}}if(data41.animationDelay !== undefined){let data53 = data41.animationDelay;if((typeof data53 == "number") && (isFinite(data53))){if(data53 < 0 || isNaN(data53)){const err110 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err110];}else {vErrors.push(err110);}errors++;}}else {const err111 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err111];}else {vErrors.push(err111);}errors++;}}if(data41.description !== undefined){let data54 = data41.description;if(typeof data54 === "string"){if(!pattern0.test(data54)){const err112 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err112];}else {vErrors.push(err112);}errors++;}}else {const err113 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err113];}else {vErrors.push(err113);}errors++;}}}else {const err114 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/1/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err114];}else {vErrors.push(err114);}errors++;}}}else {const err115 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err115];}else {vErrors.push(err115);}errors++;}var _valid0 = _errs70 === errors;if(_valid0 && valid3){valid3 = false;passing0 = [passing0, 1];}else {if(_valid0){valid3 = true;passing0 = 1;}const _errs128 = errors;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err116 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/2/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err116];}else {vErrors.push(err116);}errors++;}if(data4.primitive === undefined){const err117 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/2/required",keyword:"required",params:{missingProperty: "primitive"},message:"must have required property '"+"primitive"+"'"};if(vErrors === null){vErrors = [err117];}else {vErrors.push(err117);}errors++;}if(data4.targets === undefined){const err118 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/2/required",keyword:"required",params:{missingProperty: "targets"},message:"must have required property '"+"targets"+"'"};if(vErrors === null){vErrors = [err118];}else {vErrors.push(err118);}errors++;}for(const key7 in data4){if(!((((key7 === "id") || (key7 === "primitive")) || (key7 === "targets")) || (key7 === "options"))){const err119 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/2/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key7},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err119];}else {vErrors.push(err119);}errors++;}}if(data4.id !== undefined){let data55 = data4.id;if(typeof data55 === "string"){if(!pattern1.test(data55)){const err120 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/2/properties/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},message:"must match pattern \""+"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"+"\""};if(vErrors === null){vErrors = [err120];}else {vErrors.push(err120);}errors++;}}else {const err121 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/2/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err121];}else {vErrors.push(err121);}errors++;}}if(data4.primitive !== undefined){let data56 = data4.primitive;if(typeof data56 !== "string"){const err122 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/2/properties/primitive/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err122];}else {vErrors.push(err122);}errors++;}if("underline" !== data56){const err123 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/2/properties/primitive/const",keyword:"const",params:{allowedValue: "underline"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err123];}else {vErrors.push(err123);}errors++;}}if(data4.targets !== undefined){let data57 = data4.targets;if(Array.isArray(data57)){if(data57.length > 1){const err124 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/2/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err124];}else {vErrors.push(err124);}errors++;}if(data57.length < 1){const err125 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/2/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err125];}else {vErrors.push(err125);}errors++;}const len3 = data57.length;for(let i3=0; i3=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err160];}else {vErrors.push(err160);}errors++;}}else {const err161 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err161];}else {vErrors.push(err161);}errors++;}}if(data66.animationDelay !== undefined){let data78 = data66.animationDelay;if((typeof data78 == "number") && (isFinite(data78))){if(data78 < 0 || isNaN(data78)){const err162 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err162];}else {vErrors.push(err162);}errors++;}}else {const err163 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err163];}else {vErrors.push(err163);}errors++;}}if(data66.description !== undefined){let data79 = data66.description;if(typeof data79 === "string"){if(!pattern0.test(data79)){const err164 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err164];}else {vErrors.push(err164);}errors++;}}else {const err165 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err165];}else {vErrors.push(err165);}errors++;}}}else {const err166 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/2/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err166];}else {vErrors.push(err166);}errors++;}}}else {const err167 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/2/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err167];}else {vErrors.push(err167);}errors++;}var _valid0 = _errs128 === errors;if(_valid0 && valid3){valid3 = false;passing0 = [passing0, 2];}else {if(_valid0){valid3 = true;passing0 = 2;}const _errs186 = errors;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err168 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/3/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err168];}else {vErrors.push(err168);}errors++;}if(data4.primitive === undefined){const err169 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/3/required",keyword:"required",params:{missingProperty: "primitive"},message:"must have required property '"+"primitive"+"'"};if(vErrors === null){vErrors = [err169];}else {vErrors.push(err169);}errors++;}if(data4.targets === undefined){const err170 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/3/required",keyword:"required",params:{missingProperty: "targets"},message:"must have required property '"+"targets"+"'"};if(vErrors === null){vErrors = [err170];}else {vErrors.push(err170);}errors++;}for(const key10 in data4){if(!((((key10 === "id") || (key10 === "primitive")) || (key10 === "targets")) || (key10 === "options"))){const err171 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/3/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key10},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err171];}else {vErrors.push(err171);}errors++;}}if(data4.id !== undefined){let data80 = data4.id;if(typeof data80 === "string"){if(!pattern1.test(data80)){const err172 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/3/properties/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},message:"must match pattern \""+"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"+"\""};if(vErrors === null){vErrors = [err172];}else {vErrors.push(err172);}errors++;}}else {const err173 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/3/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err173];}else {vErrors.push(err173);}errors++;}}if(data4.primitive !== undefined){let data81 = data4.primitive;if(typeof data81 !== "string"){const err174 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/3/properties/primitive/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err174];}else {vErrors.push(err174);}errors++;}if("highlight" !== data81){const err175 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/3/properties/primitive/const",keyword:"const",params:{allowedValue: "highlight"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err175];}else {vErrors.push(err175);}errors++;}}if(data4.targets !== undefined){let data82 = data4.targets;if(Array.isArray(data82)){if(data82.length > 1){const err176 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/3/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err176];}else {vErrors.push(err176);}errors++;}if(data82.length < 1){const err177 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/3/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err177];}else {vErrors.push(err177);}errors++;}const len4 = data82.length;for(let i4=0; i4=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err220];}else {vErrors.push(err220);}errors++;}}else {const err221 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err221];}else {vErrors.push(err221);}errors++;}}if(data91.animationDelay !== undefined){let data105 = data91.animationDelay;if((typeof data105 == "number") && (isFinite(data105))){if(data105 < 0 || isNaN(data105)){const err222 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err222];}else {vErrors.push(err222);}errors++;}}else {const err223 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err223];}else {vErrors.push(err223);}errors++;}}if(data91.description !== undefined){let data106 = data91.description;if(typeof data106 === "string"){if(!pattern0.test(data106)){const err224 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err224];}else {vErrors.push(err224);}errors++;}}else {const err225 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err225];}else {vErrors.push(err225);}errors++;}}}else {const err226 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/3/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err226];}else {vErrors.push(err226);}errors++;}}}else {const err227 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/3/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err227];}else {vErrors.push(err227);}errors++;}var _valid0 = _errs186 === errors;if(_valid0 && valid3){valid3 = false;passing0 = [passing0, 3];}else {if(_valid0){valid3 = true;passing0 = 3;}const _errs256 = errors;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err228 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err228];}else {vErrors.push(err228);}errors++;}if(data4.primitive === undefined){const err229 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/required",keyword:"required",params:{missingProperty: "primitive"},message:"must have required property '"+"primitive"+"'"};if(vErrors === null){vErrors = [err229];}else {vErrors.push(err229);}errors++;}if(data4.targets === undefined){const err230 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/required",keyword:"required",params:{missingProperty: "targets"},message:"must have required property '"+"targets"+"'"};if(vErrors === null){vErrors = [err230];}else {vErrors.push(err230);}errors++;}for(const key13 in data4){if(!((((key13 === "id") || (key13 === "primitive")) || (key13 === "targets")) || (key13 === "options"))){const err231 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key13},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err231];}else {vErrors.push(err231);}errors++;}}if(data4.id !== undefined){let data107 = data4.id;if(typeof data107 === "string"){if(!pattern1.test(data107)){const err232 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/4/properties/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},message:"must match pattern \""+"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"+"\""};if(vErrors === null){vErrors = [err232];}else {vErrors.push(err232);}errors++;}}else {const err233 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/4/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err233];}else {vErrors.push(err233);}errors++;}}if(data4.primitive !== undefined){let data108 = data4.primitive;if(typeof data108 !== "string"){const err234 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/4/properties/primitive/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err234];}else {vErrors.push(err234);}errors++;}if("arrow" !== data108){const err235 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/4/properties/primitive/const",keyword:"const",params:{allowedValue: "arrow"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err235];}else {vErrors.push(err235);}errors++;}}if(data4.targets !== undefined){let data109 = data4.targets;if(Array.isArray(data109)){if(data109.length > 2){const err236 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/4/properties/targets/maxItems",keyword:"maxItems",params:{limit: 2},message:"must NOT have more than 2 items"};if(vErrors === null){vErrors = [err236];}else {vErrors.push(err236);}errors++;}if(data109.length < 2){const err237 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/4/properties/targets/minItems",keyword:"minItems",params:{limit: 2},message:"must NOT have fewer than 2 items"};if(vErrors === null){vErrors = [err237];}else {vErrors.push(err237);}errors++;}const len5 = data109.length;for(let i5=0; i5=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err285];}else {vErrors.push(err285);}errors++;}}else {const err286 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err286];}else {vErrors.push(err286);}errors++;}}if(data118.animationDelay !== undefined){let data136 = data118.animationDelay;if((typeof data136 == "number") && (isFinite(data136))){if(data136 < 0 || isNaN(data136)){const err287 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err287];}else {vErrors.push(err287);}errors++;}}else {const err288 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err288];}else {vErrors.push(err288);}errors++;}}if(data118.description !== undefined){let data137 = data118.description;if(typeof data137 === "string"){if(!pattern0.test(data137)){const err289 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err289];}else {vErrors.push(err289);}errors++;}}else {const err290 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err290];}else {vErrors.push(err290);}errors++;}}}else {const err291 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/4/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err291];}else {vErrors.push(err291);}errors++;}}}else {const err292 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/4/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err292];}else {vErrors.push(err292);}errors++;}var _valid0 = _errs256 === errors;if(_valid0 && valid3){valid3 = false;passing0 = [passing0, 4];}else {if(_valid0){valid3 = true;passing0 = 4;}const _errs334 = errors;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err293 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err293];}else {vErrors.push(err293);}errors++;}if(data4.primitive === undefined){const err294 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/required",keyword:"required",params:{missingProperty: "primitive"},message:"must have required property '"+"primitive"+"'"};if(vErrors === null){vErrors = [err294];}else {vErrors.push(err294);}errors++;}if(data4.targets === undefined){const err295 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/required",keyword:"required",params:{missingProperty: "targets"},message:"must have required property '"+"targets"+"'"};if(vErrors === null){vErrors = [err295];}else {vErrors.push(err295);}errors++;}if(data4.options === undefined){const err296 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/required",keyword:"required",params:{missingProperty: "options"},message:"must have required property '"+"options"+"'"};if(vErrors === null){vErrors = [err296];}else {vErrors.push(err296);}errors++;}for(const key16 in data4){if(!((((key16 === "id") || (key16 === "primitive")) || (key16 === "targets")) || (key16 === "options"))){const err297 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key16},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err297];}else {vErrors.push(err297);}errors++;}}if(data4.id !== undefined){let data138 = data4.id;if(typeof data138 === "string"){if(!pattern1.test(data138)){const err298 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/5/properties/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},message:"must match pattern \""+"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"+"\""};if(vErrors === null){vErrors = [err298];}else {vErrors.push(err298);}errors++;}}else {const err299 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/5/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err299];}else {vErrors.push(err299);}errors++;}}if(data4.primitive !== undefined){let data139 = data4.primitive;if(typeof data139 !== "string"){const err300 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/5/properties/primitive/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err300];}else {vErrors.push(err300);}errors++;}if("sticky" !== data139){const err301 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/5/properties/primitive/const",keyword:"const",params:{allowedValue: "sticky"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err301];}else {vErrors.push(err301);}errors++;}}if(data4.targets !== undefined){let data140 = data4.targets;if(Array.isArray(data140)){if(data140.length > 1){const err302 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/5/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err302];}else {vErrors.push(err302);}errors++;}if(data140.length < 1){const err303 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/5/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err303];}else {vErrors.push(err303);}errors++;}const len6 = data140.length;for(let i6=0; i6=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err353];}else {vErrors.push(err353);}errors++;}}else {const err354 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err354];}else {vErrors.push(err354);}errors++;}}if(data149.animationDelay !== undefined){let data167 = data149.animationDelay;if((typeof data167 == "number") && (isFinite(data167))){if(data167 < 0 || isNaN(data167)){const err355 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err355];}else {vErrors.push(err355);}errors++;}}else {const err356 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err356];}else {vErrors.push(err356);}errors++;}}if(data149.description !== undefined){let data168 = data149.description;if(typeof data168 === "string"){if(!pattern0.test(data168)){const err357 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err357];}else {vErrors.push(err357);}errors++;}}else {const err358 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err358];}else {vErrors.push(err358);}errors++;}}}else {const err359 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/5/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err359];}else {vErrors.push(err359);}errors++;}}}else {const err360 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/5/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err360];}else {vErrors.push(err360);}errors++;}var _valid0 = _errs334 === errors;if(_valid0 && valid3){valid3 = false;passing0 = [passing0, 5];}else {if(_valid0){valid3 = true;passing0 = 5;}const _errs412 = errors;if(data4 && typeof data4 == "object" && !Array.isArray(data4)){if(data4.id === undefined){const err361 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/6/required",keyword:"required",params:{missingProperty: "id"},message:"must have required property '"+"id"+"'"};if(vErrors === null){vErrors = [err361];}else {vErrors.push(err361);}errors++;}if(data4.primitive === undefined){const err362 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/6/required",keyword:"required",params:{missingProperty: "primitive"},message:"must have required property '"+"primitive"+"'"};if(vErrors === null){vErrors = [err362];}else {vErrors.push(err362);}errors++;}if(data4.targets === undefined){const err363 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/6/required",keyword:"required",params:{missingProperty: "targets"},message:"must have required property '"+"targets"+"'"};if(vErrors === null){vErrors = [err363];}else {vErrors.push(err363);}errors++;}if(data4.kind === undefined){const err364 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/6/required",keyword:"required",params:{missingProperty: "kind"},message:"must have required property '"+"kind"+"'"};if(vErrors === null){vErrors = [err364];}else {vErrors.push(err364);}errors++;}for(const key19 in data4){if(!(((((key19 === "id") || (key19 === "primitive")) || (key19 === "targets")) || (key19 === "options")) || (key19 === "kind"))){const err365 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/6/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key19},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err365];}else {vErrors.push(err365);}errors++;}}if(data4.id !== undefined){let data169 = data4.id;if(typeof data169 === "string"){if(!pattern1.test(data169)){const err366 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/6/properties/id/pattern",keyword:"pattern",params:{pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"},message:"must match pattern \""+"^[a-zA-Z0-9][a-zA-Z0-9._-]*$"+"\""};if(vErrors === null){vErrors = [err366];}else {vErrors.push(err366);}errors++;}}else {const err367 = {instancePath:instancePath+"/annotations/" + i0+"/id",schemaPath:"#/properties/annotations/items/oneOf/6/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err367];}else {vErrors.push(err367);}errors++;}}if(data4.primitive !== undefined){let data170 = data4.primitive;if(typeof data170 !== "string"){const err368 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/6/properties/primitive/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err368];}else {vErrors.push(err368);}errors++;}if("mark" !== data170){const err369 = {instancePath:instancePath+"/annotations/" + i0+"/primitive",schemaPath:"#/properties/annotations/items/oneOf/6/properties/primitive/const",keyword:"const",params:{allowedValue: "mark"},message:"must be equal to constant"};if(vErrors === null){vErrors = [err369];}else {vErrors.push(err369);}errors++;}}if(data4.targets !== undefined){let data171 = data4.targets;if(Array.isArray(data171)){if(data171.length > 1){const err370 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/6/properties/targets/maxItems",keyword:"maxItems",params:{limit: 1},message:"must NOT have more than 1 items"};if(vErrors === null){vErrors = [err370];}else {vErrors.push(err370);}errors++;}if(data171.length < 1){const err371 = {instancePath:instancePath+"/annotations/" + i0+"/targets",schemaPath:"#/properties/annotations/items/oneOf/6/properties/targets/minItems",keyword:"minItems",params:{limit: 1},message:"must NOT have fewer than 1 items"};if(vErrors === null){vErrors = [err371];}else {vErrors.push(err371);}errors++;}const len7 = data171.length;for(let i7=0; i7=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err414];}else {vErrors.push(err414);}errors++;}}else {const err415 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDuration",schemaPath:"#/properties/annotations/items/oneOf/6/properties/options/properties/animationDuration/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err415];}else {vErrors.push(err415);}errors++;}}if(data180.animationDelay !== undefined){let data194 = data180.animationDelay;if((typeof data194 == "number") && (isFinite(data194))){if(data194 < 0 || isNaN(data194)){const err416 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/6/properties/options/properties/animationDelay/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err416];}else {vErrors.push(err416);}errors++;}}else {const err417 = {instancePath:instancePath+"/annotations/" + i0+"/options/animationDelay",schemaPath:"#/properties/annotations/items/oneOf/6/properties/options/properties/animationDelay/type",keyword:"type",params:{type: "number"},message:"must be number"};if(vErrors === null){vErrors = [err417];}else {vErrors.push(err417);}errors++;}}if(data180.description !== undefined){let data195 = data180.description;if(typeof data195 === "string"){if(!pattern0.test(data195)){const err418 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/6/properties/options/properties/description/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err418];}else {vErrors.push(err418);}errors++;}}else {const err419 = {instancePath:instancePath+"/annotations/" + i0+"/options/description",schemaPath:"#/properties/annotations/items/oneOf/6/properties/options/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err419];}else {vErrors.push(err419);}errors++;}}}else {const err420 = {instancePath:instancePath+"/annotations/" + i0+"/options",schemaPath:"#/properties/annotations/items/oneOf/6/properties/options/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err420];}else {vErrors.push(err420);}errors++;}}if(data4.kind !== undefined){let data196 = data4.kind;if(typeof data196 !== "string"){const err421 = {instancePath:instancePath+"/annotations/" + i0+"/kind",schemaPath:"#/properties/annotations/items/oneOf/6/properties/kind/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err421];}else {vErrors.push(err421);}errors++;}if(!((data196 === "right") || (data196 === "wrong"))){const err422 = {instancePath:instancePath+"/annotations/" + i0+"/kind",schemaPath:"#/properties/annotations/items/oneOf/6/properties/kind/enum",keyword:"enum",params:{allowedValues: schema11.properties.annotations.items.oneOf[6].properties.kind.enum},message:"must be equal to one of the allowed values"};if(vErrors === null){vErrors = [err422];}else {vErrors.push(err422);}errors++;}}}else {const err423 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf/6/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err423];}else {vErrors.push(err423);}errors++;}var _valid0 = _errs412 === errors;if(_valid0 && valid3){valid3 = false;passing0 = [passing0, 6];}else {if(_valid0){valid3 = true;passing0 = 6;}}}}}}}if(!valid3){const err424 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/properties/annotations/items/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"};if(vErrors === null){vErrors = [err424];}else {vErrors.push(err424);}errors++;}else {errors = _errs11;if(vErrors !== null){if(_errs11){vErrors.length = _errs11;}else {vErrors = null;}}}}}else {const err425 = {instancePath:instancePath+"/annotations",schemaPath:"#/properties/annotations/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err425];}else {vErrors.push(err425);}errors++;}}}else {const err426 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err426];}else {vErrors.push(err426);}errors++;}validate10.errors = vErrors;return errors === 0;} diff --git a/docs/README.md b/docs/README.md index ea2818b..e091b3c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ library's longer explanations, API reference and contributor verification record | [How Stet works](explanation.md) | Understand element measurement, overlays and lifecycle behavior. | | [Agent usage](agent-usage.md) | Install project skills and use the agent CLI/workflow. | | [Visual handoff](visual-handoff.md) | Understand use cases and what Stet does or does not provide. | +| [Shipping with Vite](shipping.md) | Keep intentional marks while excluding temporary review content. | | [Framework examples](../examples/README.md) | Run working applications from the repository. | ## Maintaining and verifying Stet diff --git a/docs/agent-architecture.md b/docs/agent-architecture.md index e346e91..d2ff6d9 100644 --- a/docs/agent-architecture.md +++ b/docs/agent-architecture.md @@ -27,7 +27,7 @@ Current source intentionally supersedes these PRD sketches: | No requestAnimationFrame | No animation loop; shared frame-batched scroll scheduler | | Nested scrollers drift | Tracking and visibility implemented; partial clipping remains limited | | React children / optional Vue components | Explicit React target refs / Vue directives | -| Rough 6 KB core budget | Current enforced 7 KB guard; baseline 6.79 KB gzip | +| Rough 6 KB core budget | Final enforced 11 KiB raw-concatenation guard after motion and box; measured 10.36 KiB gzip | CSS baseline is 1.11 KB gzip (3 KB guard). Core and adapters have no required dependencies; React/Vue/Angular remain optional peers. Tests cover seeded geometry, cleanup, accessible descriptions, motion, tracking diff --git a/docs/agent-verification.md b/docs/agent-verification.md index fffd698..95515a4 100644 --- a/docs/agent-verification.md +++ b/docs/agent-verification.md @@ -15,8 +15,8 @@ without a new paid API integration. Release procedure: [releases.md](releases.md | `npm test` | 95 tests pass, including 58 agent checks | | `npm run check` | TypeScript, Angular and generated drift checks pass | | `npm run build` | Core/adapters compile and canonical agent artifacts regenerate | -| `npm run size` | 6,951 B core / 1,138 B CSS gzip; 7 KB / 3 KB budgets unchanged | -| `npm run test:templates` | All 35 primitive/lifecycle assets compile across five frameworks | +| `npm run size` | 10,604 B core / 1,138 B CSS gzip; within the explicit 11 KiB / 3 KiB budgets | +| `npm run test:templates` | All 40 primitive/lifecycle assets compile across five frameworks | | `npm run test:package` | Actual archive installation, CLI, types, exports, assets and browser graph pass | | `npm run test:browser` | 28 local Chromium/Firefox cases pass with inspected test-font baselines | | `npm run test:demos` | 20 cases pass across five frameworks and desktop/mobile | @@ -32,10 +32,10 @@ CI executes it instead. No privileged desktop package changes were made. ## Runtime/package impact and boundaries -`git diff 2be12b4 -- src style.css` is empty. The public runtime/adapters and CSS -retain their behavior, imports and size. Required runtime dependencies remain -zero; optional peers stay Angular >=20 <22, React >=18 <20 and Vue >=3 <4. -Circle-only/full packed browser bundles remain 3,387 / 4,957 B gzip and contain +The public runtime now includes motion, full adapter control, and box. Required +runtime dependencies remain zero; optional peers stay Angular >=20 <22, React +>=18 <20 and Vue >=3 <4. Circle-only/full packed browser bundles measure +4,645 / 6,529 B gzip and contain no agent infrastructure or Node builtins. Smaller circle-only output confirms unused primitives still shake out. Angular SSR/browser packages are dev-only. The visual specimen application explicitly adapts its own forced-color surfaces; diff --git a/docs/delivery/backlog.md b/docs/delivery/backlog.md index b8acc6f..8ccba53 100644 --- a/docs/delivery/backlog.md +++ b/docs/delivery/backlog.md @@ -20,32 +20,32 @@ See [execution rules](README.md) and the approved [contracts](contracts/). | CORE-05 | Required | B4 | CORE-04 | ACCEPTED | | CORE-06 | Required | B5 | CORE-05 | ACCEPTED | | CORE-07 | Required | Required internal B6 subset | CONTRACT-02, CORE-06 | ACCEPTED | -| CORE-08 | Required | A5 | MEASURE-01, CORE-06, BOX-02 | BLOCKED | -| AD-REACT | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | READY | -| AD-VUE | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | READY | -| AD-SVELTE | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | READY | -| AD-ANGULAR | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | READY | -| AD-INTEGRATE | Required | B7/F2 | All AD tasks | BLOCKED | -| BOX-01 | Required | C1 | CORE-06 | READY | -| BOX-02 | Required | C1/F1 | BOX-01, AD-INTEGRATE | BLOCKED | -| PW-01 | Required gate | D1/D2 | CONTRACT-03 | READY | -| PW-02 | Required gate | D1/D2/D3 | PW-01, CORE-05 | BLOCKED | -| PW-03 | Required | D2 | PW-02 | BLOCKED | -| PW-04 | Required | D2/D3 | PW-03 | BLOCKED | -| PW-05 | Required | D3 | PW-04, CORE-05 | BLOCKED | -| PW-06 | Required | D1/D3 | PW-05 | BLOCKED | -| PW-07 | Required | D4 | PW-05, CORE-06 | BLOCKED | -| PW-08 | Required | D6 | PW-05 | BLOCKED | -| SHIP-01 | Required | E2 | CONTRACT-04 | READY | -| SHIP-02 | Required | E4 | SHIP-01 | BLOCKED | -| SHIP-03 | Required | E1/F4 | SHIP-02 | BLOCKED | -| AGENT-01 | Required/final audit | F1 | AGENT-02, PW-03, AGENT-03 | BLOCKED | -| AGENT-02 | Required | F2 | AD-INTEGRATE, CORE-05, BOX-02 | BLOCKED | -| AGENT-03 | Required | F3 | PW-06, SHIP-03 | BLOCKED | -| HANDOFF-01 | Required | D7 | PW-07, PW-08, AGENT-03 | BLOCKED | +| CORE-08 | Required | A5 | MEASURE-01, CORE-06, BOX-02 | ACCEPTED | +| AD-REACT | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | ACCEPTED | +| AD-VUE | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | ACCEPTED | +| AD-SVELTE | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | ACCEPTED | +| AD-ANGULAR | Required | B7 | CONTRACT-02, CORE-06, CORE-07 | ACCEPTED | +| AD-INTEGRATE | Required | B7/F2 | All AD tasks | ACCEPTED | +| BOX-01 | Required | C1 | CORE-06 | ACCEPTED | +| BOX-02 | Required | C1/F1 | BOX-01, AD-INTEGRATE | ACCEPTED | +| PW-01 | Required gate | D1/D2 | CONTRACT-03 | ACCEPTED | +| PW-02 | Required gate | D1/D2/D3 | PW-01, CORE-05 | ACCEPTED | +| PW-03 | Required | D2 | PW-02 | ACCEPTED | +| PW-04 | Required | D2/D3 | PW-03 | ACCEPTED | +| PW-05 | Required | D3 | PW-04, CORE-05 | ACCEPTED | +| PW-06 | Required | D1/D3 | PW-05 | ACCEPTED | +| PW-07 | Required | D4 | PW-05, CORE-06 | ACCEPTED | +| PW-08 | Required | D6 | PW-05 | ACCEPTED | +| SHIP-01 | Required | E2 | CONTRACT-04 | ACCEPTED | +| SHIP-02 | Required | E4 | SHIP-01 | ACCEPTED | +| SHIP-03 | Required | E1/F4 | SHIP-02 | ACCEPTED | +| AGENT-01 | Required/final audit | F1 | AGENT-02, PW-03, AGENT-03 | ACCEPTED | +| AGENT-02 | Required | F2 | AD-INTEGRATE, CORE-05, BOX-02 | ACCEPTED | +| AGENT-03 | Required | F3 | PW-06, SHIP-03 | ACCEPTED | +| HANDOFF-01 | Required | D7 | PW-07, PW-08, AGENT-03 | READY | | WEB-01 | Required | G1 | FIX-01, CORE-06 | READY | | WEB-02 | Required | G2 | FIX-02, AGENT-02, HANDOFF-01, WEB-01 | BLOCKED | -| DOCS-01 | Required | A2/G4 | BOX-02, PW-06, SHIP-03 | BLOCKED | +| DOCS-01 | Required | A2/G4 | BOX-02, PW-06, SHIP-03 | READY | | RELEASE-01 | Required gate | H2 | CONTRACT-05, FIX-01..02, MEASURE-01, CORE-01..08, all AD, BOX-01..02, PW-01..08, SHIP-01..03, AGENT-01..03, HANDOFF-01, WEB-01..02, DOCS-01 | BLOCKED | | RELEASE-02 | Required gate | A1/G1/G2 | RELEASE-01 | BLOCKED | | FOLLOW-01..14 | Conditional/post-release | B3/B8/C2-C6/D5/E3/G3 | See task files | BLOCKED | @@ -85,5 +85,5 @@ WEB-02. The release critical path is the runtime chain through CORE-07, all adapter tasks, AD-INTEGRATE, BOX-02, AGENT-02/01, plus parallel Playwright and shipping arms that converge through AGENT-03, HANDOFF-01, WEB-02, DOCS-01, and RELEASE-01. The FIX-01 -parity approach is approved in its task packet. CORE-08 still requires an explicit -lead decision after measurement; that approval may not be inferred by a worker. +parity approach is approved in its task packet. CORE-08 received the required +explicit post-measurement 11 KiB core-budget decision on 2026-09-13. diff --git a/docs/delivery/evidence/AD-ANGULAR.md b/docs/delivery/evidence/AD-ANGULAR.md new file mode 100644 index 0000000..21ce284 --- /dev/null +++ b/docs/delivery/evidence/AD-ANGULAR.md @@ -0,0 +1,11 @@ +# AD-ANGULAR evidence + +- Revision/base: `d6fd6e7` +- Environment: Linux; Node v26.7.0; npm 11.19.0 +- Files changed: `src/angular.ts`, `tests/angular.test.ts` +- Acceptance criteria checked: Angular and ngc compilation; inherited `stetOnHandle` input emission; render-safe attachment retained; source review of changed-only visibility, nonreplaying complete updates, Arrow replacement, callback transfer, and destroy/null ordering. +- Commands and exit status: `npx oxfmt src/angular.ts tests/angular.test.ts` — 0; `npx oxlint src/angular.ts tests/angular.test.ts` — 0; final `npx vitest run tests/angular.test.ts` — 0 (13 passed); final `npm run check` — 0; final `npm run test:patterns` — 0 (18 passed: Chromium and Firefox). Earlier harness/assertion failures were resolved before acceptance. +- Browser/manual artifacts: Existing pattern lifecycle and SSR checks passed, but the Angular pattern uses core primitives and does not instantiate the Angular directives. +- Pre-existing failures: None observed. +- Unverified items and reason: None required by AD-ANGULAR. +- Reviewer decision: APPROVE after the fresh 13/13 adapter run and successful compile/check closed the prior evidence blocker. diff --git a/docs/delivery/evidence/AD-INTEGRATE.md b/docs/delivery/evidence/AD-INTEGRATE.md new file mode 100644 index 0000000..4a468c0 --- /dev/null +++ b/docs/delivery/evidence/AD-INTEGRATE.md @@ -0,0 +1,10 @@ +# AD-INTEGRATE evidence + +- Revision/base: `d6fd6e7` +- Environment: Linux; Node v26.7.0; npm 11.19.0 +- Files changed during integration: `src/react.ts`, `tests/react.test.ts` +- Parity matrix: vanilla exposes the handle directly; React, Vue, Svelte, and Angular expose the same handle through their idiomatic callbacks. All adapters implement changed-only visibility, complete nonreplaying updates, callback transfer, teardown null, missing-target silence, target replacement, current declarative visibility on replacement, and deferred client attachment. React additionally preserves StrictMode ordering. +- Metadata: `onHandle`/`stetOnHandle` remain adapter-only and absent from runtime plans, schemas, and capabilities. +- Commands and exit status: `npx vitest run tests/react.test.ts tests/vue.test.ts tests/svelte.test.ts tests/angular.test.ts` — 0 (57 passed); `npm run check` — 0; `npm run test:templates` — 0 (35 snippets/patterns, no diagnostics); `npm run test:patterns` — 0 (18 passed: Chromium and Firefox). +- Proven integration gap corrected: React now matches Vue/Svelte/Angular by completing valid target replacement before rethrowing an outgoing callback exception; focused React suite passed 18/18. +- Reviewer decision: APPROVE; no confirmed released-behavior parity gap. diff --git a/docs/delivery/evidence/AD-REACT.md b/docs/delivery/evidence/AD-REACT.md new file mode 100644 index 0000000..c9e937c --- /dev/null +++ b/docs/delivery/evidence/AD-REACT.md @@ -0,0 +1,11 @@ +# AD-REACT evidence + +- Revision/base: `d6fd6e7` +- Environment: Linux; Node v26.7.0; npm 11.19.0 +- Files changed: `src/react.ts`, `tests/react.test.ts` +- Acceptance criteria checked: React 18-compatible handle callbacks; StrictMode cleanup ordering; callback identity transfer without remount; changed-only visibility; nonreplaying complete option updates; imperative visibility preservation; stable-ref target replacement; Arrow target replacement. +- Commands and exit status: final `npx vitest run tests/react.test.ts` — 0 (18 passed); `npm run check` — 0; `npm run test:patterns` — 0 (18 passed: Chromium and Firefox); `npx oxfmt src/react.ts tests/react.test.ts` — 0; `npx oxlint src/react.ts tests/react.test.ts` — 0. +- Browser/manual artifacts: Pattern lifecycle and SSR checks passed in Chromium and Firefox; ordinary temporary output was not retained. +- Pre-existing failures: None observed. The pattern server's expected missing `favicon.ico` requests did not fail tests. +- Unverified items and reason: None required by AD-REACT. +- Reviewer decision: APPROVE after callback-transfer and target-replacement exception findings were fixed and regression-tested. diff --git a/docs/delivery/evidence/AD-SVELTE.md b/docs/delivery/evidence/AD-SVELTE.md new file mode 100644 index 0000000..f902d79 --- /dev/null +++ b/docs/delivery/evidence/AD-SVELTE.md @@ -0,0 +1,11 @@ +# AD-SVELTE evidence + +- Revision/base: `d6fd6e7` +- Environment: Linux; Node v26.7.0; npm 11.19.0 +- Files changed: `src/svelte.ts`, `tests/svelte.test.ts` +- Acceptance criteria checked: supplied handle/null lifecycle; callback transfer without recreation; changed-only visibility and imperative-state preservation; complete nonreplaying updates; optional action options; missing/replaced Arrow targets and ARIA cleanup; callback exception continuation; no Svelte-specific controller. +- Commands and exit status: `npx vitest run tests/svelte.test.ts` — 0 (14 passed); `npm run check` — 0; `npm run test:patterns` — 0 (18 passed: Chromium and Firefox); `npx oxfmt src/svelte.ts tests/svelte.test.ts` — 0; `npx oxlint src/svelte.ts tests/svelte.test.ts` — 0. +- Browser/manual artifacts: Pattern lifecycle and SSR checks passed in Chromium and Firefox; ordinary temporary output was not retained. +- Pre-existing failures: The first implementation type-check failed on Svelte's conditional-tuple `Action` signature; corrected with a localized public-shape cast. Expected missing `favicon.ico` requests did not fail pattern tests. +- Unverified items and reason: None required by AD-SVELTE. +- Reviewer decision: APPROVE; no confirmed contract or implementation defects. diff --git a/docs/delivery/evidence/AD-VUE.md b/docs/delivery/evidence/AD-VUE.md new file mode 100644 index 0000000..ca34111 --- /dev/null +++ b/docs/delivery/evidence/AD-VUE.md @@ -0,0 +1,11 @@ +# AD-VUE evidence + +- Revision/base: `d6fd6e7` +- Environment: Linux; Node v26.7.0; npm 11.19.0 +- Files changed: `src/vue.ts`, `tests/vue.test.ts` +- Acceptance criteria checked: handle/null directive lifecycle; callback transfer without remount; changed-only visibility and imperative-state preservation; complete nonreplaying updates; missing and replaced Arrow targets; host replacement; callback exception ordering; adapter metadata excluded from runtime options. +- Commands and exit status: `npx vitest run tests/vue.test.ts` — 0 (12 passed); `npm run check` — 0; `npm run test:patterns` — 0 (18 passed: Chromium and Firefox); `npx oxfmt src/vue.ts tests/vue.test.ts` — 0; `npx oxlint src/vue.ts tests/vue.test.ts` — 0. +- Browser/manual artifacts: Pattern lifecycle and SSR checks passed in Chromium and Firefox; ordinary temporary output was not retained. +- Pre-existing failures: None observed. Expected missing `favicon.ico` requests did not fail pattern tests. +- Unverified items and reason: None required by AD-VUE. +- Reviewer decision: APPROVE after missing-target and callback-exception replacement findings were fixed and regression-tested. diff --git a/docs/delivery/evidence/AGENT-01-PROVISIONAL.md b/docs/delivery/evidence/AGENT-01-PROVISIONAL.md new file mode 100644 index 0000000..d01beb7 --- /dev/null +++ b/docs/delivery/evidence/AGENT-01-PROVISIONAL.md @@ -0,0 +1,11 @@ +# AGENT-01 provisional evidence + +- Revision/base: `d6fd6e7` +- Scope decision: the integrator temporarily waived PW-03 and AGENT-03 on 2026-09-13. This audit covers the current runtime, adapters, box, and motion surface only. AGENT-01 must reopen after those prerequisites are accepted. +- Audit matrix: seven runtime primitives match catalog and generated core exports; all five framework symbol maps match adapter exports; option schemas/defaults derive from TypeScript and catalog; box/circle/underline reveal support matches runtime and plan validation; adapter callback metadata is absent from plans; generated artifacts match sources. +- Compatibility: an explicit test keeps version-1 plans using the original circle, underline, highlight, arrow, sticky, and mark primitives valid. +- Corrected drift: generated lifecycle constraints now list `show()`, `hide()`, and `replay()` in addition to refresh, resketch, and destroy. +- Commands and exit status: `npm run check` — 0; `npm run test:agent` — 0 (64 passed); `npm run test:templates` — 0 (40 compiled); `npm run test:package` — 0 (zero runtime dependencies; circle/full 4,645/6,529 B gzip); focused runtime/schema tests — 0 (228 passed). +- Generated status: `agent:check` passed after regeneration; generated files were not edited manually. +- Reviewer decision: APPROVE PROVISIONAL with no current-surface blocker. Generated TypeScript/capability option bags remain intentionally broader than conditional JSON-schema validation; runtime and executable plan validation agree. +- Deferred full-audit scope: PW-03 package/Playwright exports, AGENT-03 installed skills/routing, and resulting full-release generated-contract drift. diff --git a/docs/delivery/evidence/AGENT-01.md b/docs/delivery/evidence/AGENT-01.md new file mode 100644 index 0000000..c99a1d2 --- /dev/null +++ b/docs/delivery/evidence/AGENT-01.md @@ -0,0 +1,9 @@ +# AGENT-01 final audit evidence + +- Base: AGENT-02, PW-03, and AGENT-03 accepted. This replaces the provisional audit as the integrated release audit; the provisional record remains for history. +- Generated source: `agent/catalog.mjs`, runtime declarations, `package.json` exports, and `agent/evals/routing.json` feed `scripts/agent/generate.mjs`. Generated files were refreshed only with `npm run agent:generate` and the final `agent:check` found no drift. +- Runtime/plan agreement: all seven runtime primitives and five framework export maps remain catalogued; option/default and conditional plan validation tests pass. Adapter callback metadata remains outside plans. Explicit compatibility tests keep version-1 plans using the original six primitives valid. +- PW-03 integration: generated capabilities and their schema contain the installed `./playwright` export. Packed-package checks assert its optional peer range, JS/declaration/payload files, public import resolution, browser graph isolation, and types-only caller-supplied Playwright import. +- AGENT-03 integration: routing source generates identical positive, negative, and ambiguous fixtures for every installed skill; packaged skill/reference traversal and managed installation checks pass. +- Commands: `npm run check` exited `0`; `npm run test:agent` exited `0` (9 files, 64 tests); `npm run test:templates` exited `0` (40 framework snippets/lifecycle patterns); `npm run test:package` exited `0` (308 packed files, zero consumer runtime dependencies, `./playwright` exercised, circle/full browser gzip 4,645/6,529 bytes). +- Reviewer decision: ACCEPT after independent inspection confirmed generated runtime/adapter facts, plan restrictions and compatibility, Playwright package metadata, skill routing, and drift coverage. The integrator ran all four required commands immediately before review. diff --git a/docs/delivery/evidence/AGENT-02.md b/docs/delivery/evidence/AGENT-02.md new file mode 100644 index 0000000..5c68275 --- /dev/null +++ b/docs/delivery/evidence/AGENT-02.md @@ -0,0 +1,9 @@ +# AGENT-02 evidence + +- Revision/base: `d6fd6e7` +- Source writers: `agent/snippets.mjs`, `agent/patterns.mjs`; templates regenerated through `npm run agent:generate` only. +- Recipes: static primitive snippets plus a hidden animated box and sequential public-handle `hide`, awaited `show`, awaited `replay`, and final `hide`. A cancelled show or replay returns immediately. Reduced motion is delegated to the accepted runtime; framework cleanup/destroy cancels active work. +- Adapter handles: React uses stable `useCallback`; Vue and Svelte include `onHandle` as adapter metadata; Angular binds `stetOnHandle`; vanilla receives the handle directly. +- Commands and exit status: `npm run test:agent` — 0 (63 passed); `npm run check` — 0; `npm run test:templates` — 0 (40 generated assets); `npm run test:patterns` — 0 (18 passed: Chromium and Firefox); formatter/lint — 0. +- Generated status: every checked-in template matched generator output; callback metadata remained absent from capabilities and plan schemas. +- Reviewer decision: APPROVE; cancellation, cleanup, reduced-motion guidance, idiomatic callbacks, and no invented orchestration API were confirmed. diff --git a/docs/delivery/evidence/AGENT-03.md b/docs/delivery/evidence/AGENT-03.md new file mode 100644 index 0000000..707e34f --- /dev/null +++ b/docs/delivery/evidence/AGENT-03.md @@ -0,0 +1,9 @@ +# AGENT-03 evidence + +- Base: PW-06 and SHIP-03 accepted. +- Delivery decision: the base Stet skill now routes durable/published or non-Playwright annotations to application source, temporary screenshot/test artifacts with a caller-owned supported Playwright context to injection, and temporary in-app review content to the tested source boundary. +- Ambiguity: the skill asks one precise lifetime/artifact/context question rather than silently choosing. It states unsupported cross-origin/opaque or detached frames, workers, browser contexts, locator/FrameLocator contexts, element handles, cross-document arrows, navigation/frame-detachment reuse, restrictive CSP, and unclaimed WebKit. +- Boundaries: both delivery paths require independent native behavior/accessibility/cleanup verification. The skill says Stet does not run agents, perform QA, apply plans, edit source automatically, contact users, publish, or deploy. +- Routing fixtures: added a durable published onboarding case (`stet-explain-ui`), temporary Playwright injection case (`stet`), ambiguous lifetime case (`stet` for clarification), and negative Playwright-without-Stet case (`none`). `npm run agent:generate` refreshed canonical per-skill trigger evaluations. +- Verification: `npm run test:agent` exited `0`: 9 files and 64 tests passed, including routing, skill integrity, generated drift, installer, schema, and CLI checks. +- Reviewer decision: ACCEPT after independent review; requested clarifications for branch-specific implementation/recovery and the exact Playwright context matrix were corrected and re-reviewed. diff --git a/docs/delivery/evidence/BOX-01.md b/docs/delivery/evidence/BOX-01.md new file mode 100644 index 0000000..74b4668 --- /dev/null +++ b/docs/delivery/evidence/BOX-01.md @@ -0,0 +1,9 @@ +# BOX-01 evidence + +- Revision/base: `d6fd6e7` +- Files changed: `src/rough.ts`, `tests/rough.test.ts`, `tests/browser/visual.spec.ts`, and reviewed Chromium/Firefox box-geometry snapshots. +- Geometry: one deterministic open perimeter gesture with four sides and a short finishing overlap; seed, roughness, and boil use the existing pen source. Zero, hairline, and small dimensions remain finite. +- Reveal: the same continuous stroked path renders correctly with normalized `pathLength`, dash array, and partial dash offset. +- Commands and exit status: `npx vitest run tests/rough.test.ts` — 0 (15 passed); `npm run check` — 0; focused Playwright box visual test — 0 (Chromium and Firefox); formatter/lint — 0. +- Visual review: APPROVE. Both browsers show coherent hand-drawn perimeters, sensible partial reveal, bounded tiny geometry, and only antialiasing-level differences. +- Reviewer decision: APPROVE; no confirmed geometry or scope defect. diff --git a/docs/delivery/evidence/BOX-02.md b/docs/delivery/evidence/BOX-02.md new file mode 100644 index 0000000..88958fa --- /dev/null +++ b/docs/delivery/evidence/BOX-02.md @@ -0,0 +1,10 @@ +# BOX-02 evidence + +- Revision/base: `d6fd6e7` +- Public surface: vanilla `box`, React `Box`, Vue `vStetBox`, Svelte `box`, Angular `StetBoxDirective`. +- Runtime: `box()` uses accepted `roughBox` geometry, the existing mount/update handle, and the shared stroked reveal state machine. Default padding is 5. Static is default; validated animation settings use the next entrance. +- Agent surface: catalog, capabilities, plan schema/types/validator, CLI snippets, and all five framework templates were regenerated from source. Box is reveal-supported in runtime and plan generation; callback metadata remains excluded. +- Commands and exit status: `npm test` — 0 (332 passed); `npm run check` — 0; `npm run test:templates` — 0 (40 snippets/patterns); `npm run test:patterns` — 0 (18 passed); `npm run test:package` — 0; focused browser reveal suite — 0 (72 passed across Chromium/Firefox); focused runtime/agent tests — 0 (237 passed). +- Package result: 720,552-byte archive; 1,461,735 unpacked bytes; 279 files; zero consumer runtime dependencies; circle-only 4,645 gzip bytes vs full 6,529, confirming tree-shaking. +- Generated diff: box templates added for vanilla, React, Vue, Svelte, and Angular; capabilities/schema/type/validator outputs changed only through `npm run agent:generate`; subsequent `agent:check` passed. +- Reviewer decision: APPROVE; no confirmed runtime, schema, adapter, or packaging defect. diff --git a/docs/delivery/evidence/CORE-08.md b/docs/delivery/evidence/CORE-08.md new file mode 100644 index 0000000..37a19ba --- /dev/null +++ b/docs/delivery/evidence/CORE-08.md @@ -0,0 +1,11 @@ +# CORE-08 measurement evidence + +- Revision/base: `d6fd6e7` +- Commands: pre-decision `npm run size` — 1 because the former 7 KiB guard was exceeded; final `npm run size` with the approved explicit ceiling — 0. All graph/tree-shaking assertions passed. Final `npm run test:package` — 0. +- Current measurements: legacy core 10,604 B gzip; CSS 1,138 B; minimal circle 4,645 B; full core 6,529 B; React 7,203 B; Vue 7,228 B; Svelte 7,202 B; Angular 8,044 B. +- MEASURE-01 deltas: core +3,615 B; CSS +0; circle +1,258 B; full +1,543 B; React +1,768 B; Vue +1,972 B; Svelte +1,994 B; Angular +2,096 B. +- Package: zero runtime dependencies; package installation, exports, assets, and browser graph passed. Archive bytes and file count vary as this required evidence file is added, so they are not treated as a reproducible budget. +- Attribution: 3,180 B of legacy core growth is `mount.ts` motion/reveal behavior; rough geometry +276 B; primitives +123 B; index +3 B. No agent, Node, Playwright, host framework, or unexpected module leaked into browser bundles. +- Tree-shaking: circle remains smaller than full; box, arrow, sticky, and mark code are absent from the circle-only output. Circle still shares the existing single-primitive branches. +- Decision: the integrator explicitly approved an 11 KiB legacy raw-concatenation core ceiling on 2026-09-13. The 3 KiB CSS ceiling is unchanged. Consumer bundle measurements remain reports, with graph and circle-versus-full tree-shaking assertions enforced. +- Reviewer decision: APPROVE. Final size and package checks passed; the explicit 11 KiB/3 KiB guards, measurements, graph exclusions, and tree-shaking were independently reproduced. diff --git a/docs/delivery/evidence/PW-01.md b/docs/delivery/evidence/PW-01.md new file mode 100644 index 0000000..40b03b5 --- /dev/null +++ b/docs/delivery/evidence/PW-01.md @@ -0,0 +1,13 @@ +# PW-01 evidence + +- Revision/base: `d6fd6e7` with the existing phase-4 working tree. +- Environment: Linux; Node `v26.7.0`; npm `11.19.0`; Playwright `1.63.0`; Chromium and Firefox projects. `STET_WEBKIT` was not set, so no WebKit result is claimed. +- Files changed: isolated `playwright.pw01.config.ts` and `tests/pw01/` investigation config, loader, payload build, fixtures, and tests. No package export or production helper was added. +- Acceptance criteria checked: the real `src/index.ts` browser graph was bundled as a test-only IIFE and loaded with the real `style.css`; static same-origin URLs and Playwright-routed same-origin URLs rendered in Chromium and Firefox under `script-src 'self'; style-src 'self'`; inline script/style were blocked under that policy and worked without CSP; `script-src 'none'; style-src 'none'` rejected URL loading and rollback left no injected nodes or payload; a same-origin frame rendered and styled inside its own document; a foreign annotation's computed styles were unchanged when Stet CSS was first loaded; source-authored Stet retained its path, resources, and overlay through helper cleanup; simulated CSS-first and JS-second network failures removed partial resources. +- Compared approaches and selected mechanism: reject inline `addScriptTag`/`addStyleTag` because restrictive CSP blocks them, and reject `page.evaluate` payload execution because it silently bypasses page policy. Carry forward a prebuilt classic/IIFE browser payload plus stylesheet loaded through same-origin URL requests fulfilled from package-local files by the caller's Playwright context. Both direct static URLs and routed URLs worked; routed URLs are the portable candidate for PW-02 packed-package discovery. Final asset discovery, CSP/load error classification, and shared resource lifetime remain gated by PW-02. +- Commands and exit status: `npx playwright test -c playwright.pw01.config.ts` initially exited `1` with Chromium 10/10 and Firefox 9/10 because Firefox emitted non-CSP page-console messages but did not forward its CSP violation there. The assertion was corrected to use the portable rejected `addStyleTag` operation rather than console text. A first rerun exited `0` with 20 passing in 5.6s. Independent review then found that the foreign-style check started after source-authored Stet CSS was already loaded; a no-prior-Stet-CSS assertion was added. The final rerun exited `0`: 20 passed across Chromium and Firefox in 5.4s. `node --version && npm --version && npx playwright --version` exited `0`. +- Browser/manual artifacts: Playwright run output under ignored `test-results/pw01/`; no visual snapshot is used as behavior proof. +- Supported matrix: Chromium and Firefox support Page and same-origin Frame loading when page policy permits same-origin script and style. Both reject inline transport under restrictive CSP and reject all transports under `script-src 'none'; style-src 'none'`; no bypass is enabled. Cross-origin frames were not in PW-01 scope and are unsupported by contract. WebKit was not run. +- Pre-existing failures: none encountered by the dedicated suite. +- Unverified items and reason: packed-package asset discovery, navigation while animation is pending, final resource lifetime, and browser-core graph inspection belong to PW-02. Public session behavior belongs to PW-03 through PW-06. +- Reviewer decision: ACCEPT after independent review found the initial foreign-style assertion tautological; the corrected no-prior-Stet-CSS check passed in both required browsers. diff --git a/docs/delivery/evidence/PW-02.md b/docs/delivery/evidence/PW-02.md new file mode 100644 index 0000000..ec59eb0 --- /dev/null +++ b/docs/delivery/evidence/PW-02.md @@ -0,0 +1,14 @@ +# PW-02 evidence + +- Revision/base: `d6fd6e7` with the existing phase-4 working tree and accepted PW-01 investigation. +- Environment: Linux; Node `v26.7.0`; npm `11.19.0`; Playwright `1.63.0`; Chromium and Firefox. `STET_WEBKIT` was not set, so no WebKit result is claimed. +- Files changed: isolated `playwright.pw02.config.ts` and `tests/pw02/` packed-consumer build experiment, emulated non-exported entry, fixtures, and tests. Generated payload, package, install, hashes, and graph reports remain under ignored `test-results/pw02/` and `dist/playwright/`. +- Acceptance criteria checked: `npm pack` included the prebuilt payload, colocated experimental entry, root stylesheet, and browser core; a fresh offline consumer installed that tarball without esbuild or network access; the entry discovered assets relative to its own installed `import.meta.url`; Chromium and Firefox rendered the real runtime and stylesheet from same-origin fulfilled URLs; navigation during a 60-second reveal rejected the pending call as `NAVIGATION` within five seconds, permanently invalidated the session, and did not reinject; disposing either of two owned sessions preserved the other session, source-authored Stet, and a foreign annotation; payload/style failures reported `LOAD_FAILED` and rolled back owned nodes. +- Package evidence: `funsaized-stet-0.1.0.tgz` was 732,283 bytes packed and 1,521,243 bytes unpacked with 286 files. Offline npm install succeeded. Required paths included `dist/playwright/entry.mjs`, `dist/playwright/payload.js`, `dist/index.js`, and `style.css`; package metadata still had no public `./playwright` export. Installed payload SHA-256 was `5619b843ba3a856ae7bfc71893c67fc79c38a249c0d04c10f2144a70c07e90b1`; stylesheet SHA-256 was `5a7efbfbfc4206165068ca28c42664827763173035ce8fd6896f28f77545efb7`. +- Graph evidence: the payload bundle inputs were only `src/index.ts`, `src/mount.ts`, `src/primitives.ts`, `src/prng.ts`, and `src/rough.ts`. The packed root browser-core bundle inputs were only `` and installed `dist/index.js`, `mount.js`, `primitives.js`, `prng.js`, and `rough.js`; emitted browser output had no external imports. Neither graph contained Node or Playwright code. Esbuild was used only by Stet's provider-side experiment and graph inspection, not by the fresh consumer. +- Accepted loader and packaging mechanism: Stet's own build emits a classic/IIFE browser payload beside the optional Node-side entry. The entry resolves that payload and the root stylesheet relative to its installed `import.meta.url`, reads them package-locally, and fulfills opaque same-origin URLs through the caller's Playwright browser context. The bound Page or same-origin Frame loads those URLs with `addScriptTag({ url })` and `addStyleTag({ url })`, so browser CSP remains active. Browser runtime state is shared per page document; each session owns its handles and injected tag pair, and the final owner drops the runtime. Navigation invalidates rather than reloads. Inline/evaluate payload execution, CDN loading, and consumer bundling are rejected. +- Commands and exit status: `npx playwright test -c playwright.pw02.config.ts` exited `0`: payload build and graph assertions passed, offline packed install passed, and 10 tests passed across Chromium and Firefox in 5.1s. +- Browser/manual artifacts: ignored `test-results/pw02/env.json`, `payload-graph.json`, tarball, fresh consumer, and Playwright run output. No visual snapshot or annotation-presence-only result was used as proof. +- Pre-existing failures: none encountered by the dedicated suite. +- Unverified items and reason: public exports/types and production integration belong to PW-03; the full locator/session and error matrix belongs to PW-04 through PW-06. Portable distinction between CSP policy rejection and another tag-load failure remains to be frozen with that permanent matrix; neither case may be bypassed. +- Reviewer decision: ACCEPT after independent review confirmed the installed-tarball path, offline consumer, navigation result, ownership rollback, and isolated browser graphs. diff --git a/docs/delivery/evidence/PW-03.md b/docs/delivery/evidence/PW-03.md new file mode 100644 index 0000000..f266f3a --- /dev/null +++ b/docs/delivery/evidence/PW-03.md @@ -0,0 +1,12 @@ +# PW-03 evidence + +- Revision/base: `d6fd6e7` with the existing phase-4 working tree and accepted PW-01/PW-02 design. +- Environment: Linux; Node `v26.7.0`; npm `11.19.0`; Playwright `1.63.0`. +- Files changed: `src/playwright/entry.ts`, `scripts/build-playwright.mjs`, `package.json`, `package-lock.json`, `scripts/agent/check-package.mjs`, generator-produced package-export changes in `agent/capabilities.json` and `agent/schemas/capabilities.schema.json`, and this evidence. Existing unrelated generated box/motion changes were retained. +- Acceptance criteria checked: `@funsaized/stet/playwright` resolves its JS and declarations from an installed tarball; `dist/playwright/payload.js` is packed beside the entry; caller-supplied Page/Frame and locator types use the optional `@playwright/test >=1.63 <2` peer with no runtime Playwright import; the entry discovers and reads package-local payload/CSS without CDN or consumer build tooling; root imports still bundle only the five browser-core modules; generated capabilities list `./playwright`; repeated builds emitted the same payload hash. +- Commands and exit status: the first `npm run check` exited `1` only because the newly approved export made generated capabilities stale. `npm run agent:generate && npm run check` exited `0`. `npm run test:package` exited `0`: fresh offline tarball install and runtime import smoke passed. After review noted a stale PW-02 experiment could remain under `dist/playwright`, the production build was changed to remove it and `npm run test:package` again exited `0`: archive 735,826 bytes / 1,538,902 unpacked / 289 files / zero consumer runtime dependencies, root circle/full browser graphs 4,645/6,529 bytes gzip. `npm run size` exited `0`: core 10,604 bytes gzip, CSS 1,138 bytes gzip, and all existing adapter limits passed. +- Build/graph artifacts: both `test:package` and `size` independently emitted a 35,925-byte payload with SHA-256 `19333192505bb694ea23f9ad16a266134e8ab684496eae7766abda4a90ddb30f`; inputs were only `src/index.ts`, `src/mount.ts`, `src/primitives.ts`, `src/prng.ts`, and `src/rough.ts`. Packed root import graphs remained restricted to `dist/index.js`, `mount.js`, `primitives.js`, `prng.js`, and `rough.js`, with no Node or Playwright module. +- Browser/manual artifacts: none required by PW-03; PW-01/PW-02 retain the Chromium/Firefox loader evidence. Package checker used a temporary fresh consumer and removed it after success. +- Pre-existing failures: none. The initial generated-drift failure was an expected consequence of the new export and was corrected only through `npm run agent:generate`. +- Unverified items and reason: exact locator/document errors and session attachment behavior belong to PW-04; remote interruption, lifecycle invalidation, and cleanup depth belong to PW-05; the permanent frame/CSP matrix belongs to PW-06. +- Reviewer decision: ACCEPT after independent review confirmed package resolution, types-only peer usage, CSP-active asset loading, reproducible payload, generated export drift, and browser-core isolation. diff --git a/docs/delivery/evidence/PW-04.md b/docs/delivery/evidence/PW-04.md new file mode 100644 index 0000000..b5f01c6 --- /dev/null +++ b/docs/delivery/evidence/PW-04.md @@ -0,0 +1,12 @@ +# PW-04 evidence + +- Revision/base: `d6fd6e7` with accepted PW-01 through PW-03 changes in the working tree. +- Environment: Linux; Node `v26.7.0`; npm `11.19.0`; Playwright `1.63.0`; Chromium and Firefox. `STET_WEBKIT` was not set. +- Files changed: `src/playwright/entry.ts`, `playwright.pw04.config.ts`, `tests/pw04/`, and this evidence. +- Acceptance criteria checked: Page, HTTP same-origin Frame, and inherited-origin `about:blank` Frame sessions loaded and attached all six public primitives; cross-origin and detached Frames and non-Page/Frame objects rejected before helper mutation; locator resolution uses one non-waiting `elementHandles()` snapshot, validates owner frame, then validates captured node connectivity in the bound document; missing, ambiguous, race-detached/replaced, and wrong-document targets produced distinct codes and roles; arrows checked `from` then `to` and never attached across documents; invalid runtime options rolled back; two sessions and source-authored/foreign content remained independent. +- Identity/race evidence: the deterministic race wrapper captured the original exact-one ElementHandle, replaced the matching DOM node before attachment, and received `TARGET_DETACHED`; the replacement was not rediscovered and no helper overlay was added. +- Commands and exit status: `npm run check` exited `0`. The first `npx playwright test -c playwright.pw04.config.ts` run exited `1` with 10 passing and 6 failures caused by three fixture assertions (source-authored baseline count, unsupported Locator.remove call, and an option that was valid). After correcting those checks, the second run had 15 passing and one Firefox failure because its serialized runtime validation message omitted the error-class prefix. Classification was narrowed to Stet's stable validation text. The final dedicated run exited `0`: 16 passed across Chromium and Firefox in 13.8s. +- Browser/manual artifacts: ignored `test-results/pw04/`; no visual assertion was used as behavior proof. +- Pre-existing failures: none in the required checks. +- Unverified items and reason: concurrent remote-call joining, interruption/navigation/closure, destroyed-handle semantics, and full cleanup lifecycle belong to PW-05. Permanent CSP/support documentation belongs to PW-06. +- Reviewer decision: ACCEPT after independent review confirmed the public non-waiting snapshot, captured-node identity, ordered errors/roles, frame boundary, arrow rollback, and mixed ownership. Empty wrong-frame locators remain indistinguishable from missing through Playwright's public API. diff --git a/docs/delivery/evidence/PW-05.md b/docs/delivery/evidence/PW-05.md new file mode 100644 index 0000000..8b6b945 --- /dev/null +++ b/docs/delivery/evidence/PW-05.md @@ -0,0 +1,13 @@ +# PW-05 evidence + +- Revision/base: `d6fd6e7` with accepted PW-01 through PW-04 work. +- Environment: Linux; Node `v26.7.0`; npm `11.19.0`; Playwright `1.63.0`; Chromium and Firefox. `STET_WEBKIT` was not set. +- Files changed: lifecycle implementation in `src/playwright/entry.ts`, `playwright.pw05.config.ts`, `tests/pw05/`, and this evidence. +- Acceptance criteria checked: the exact same Node promise joins an in-flight show; hide, replay, handle destroy, and session dispose interrupt without hanging; post-destroy methods follow runtime results; session disposal is idempotent; main- and child-frame navigation, frame detachment, and page closure reject pending calls with distinct lifecycle codes; later calls remain invalidated; no reinjection occurs; session cleanup retains source-authored and other-session annotations; an injected browser exception remains `BROWSER_OPERATION`. +- Firefox race investigation: Firefox rejects the pending child-frame evaluation with the same navigation-shaped message for both navigation and removal before `framenavigated` or `framedetached` fires; `frame.isDetached()` remains false through immediate event-loop turns. The accepted implementation waits only for the exact bound child Frame's next lifecycle event for that ambiguous Playwright message. `framenavigated` maps to `NAVIGATION`, `framedetached` to `FRAME_DETACHED`, and page close to `PAGE_CLOSED`; no arbitrary sleep or message-only guess is used. +- Attempts and correction: the initial full matrix exited `1` with 9 passing and 3 failures; two were fixture issues. The Firefox detachment case then remained after lifecycle precedence and a synchronous `isDetached()` check, satisfying the stop condition and producing a blocked report. Work resumed only after explicit user direction. A read-only timing probe established the deterministic lifecycle-event boundary; the corrected focused Firefox reproduction passed, and a sibling child-frame navigation case was added to prove the discriminator in both directions. +- Commands and exit status: focused corrected Firefox detachment command exited `0`, 1 passed. Final `npm run check` exited `0`. Final `npx playwright test -c playwright.pw05.config.ts` exited `0`, 14 passed across Chromium and Firefox in 7.3s. Earlier failed outcomes remain recorded above rather than reported as passing. +- Browser/manual artifacts: runnable lifecycle matrix retained; ordinary Playwright output remains ignored. WebKit was not run and is not claimed. +- Pre-existing failures: none. +- Unverified items and reason: CSP and permanent compatibility assertions belong to PW-06; deterministic screenshot and independent application behavior belong to PW-07/PW-08. +- Reviewer decision: ACCEPT after independent review confirmed the event-driven Firefox discriminator, Promise identity, interruption, destroyed-session behavior, ownership, cleanup, and visible residual errors. diff --git a/docs/delivery/evidence/PW-06.md b/docs/delivery/evidence/PW-06.md new file mode 100644 index 0000000..431b761 --- /dev/null +++ b/docs/delivery/evidence/PW-06.md @@ -0,0 +1,13 @@ +# PW-06 evidence + +- Revision/base: `d6fd6e7` with PW-01 through PW-05 accepted. +- Environment: Linux; Node `v26.7.0`; npm `11.19.0`; Playwright `1.63.0`; Chromium and Firefox. `STET_WEBKIT` was not set, so WebKit is not claimed. +- Files changed: CSP classification in `src/playwright/entry.ts`, permanent `playwright.pw06.config.ts` and `tests/pw06/` matrix, `docs/playwright.md`, and this evidence. +- Acceptance criteria checked: Page and same-origin Frame load and render the real production payload/CSS under `script-src 'self'; style-src 'self'` in Chromium and Firefox; a fully blocking policy rejects `CSP_BLOCKED` with a recovery hint and complete rollback; a separate non-policy tag failure rejects actionable `LOAD_FAILED`; the temporary policy probe is removed on success and both failures; the policy remains active because only URL-backed tags are used; cross-origin and opaque sandboxed Frames and non-Page/Frame contexts reject without mutation; cross-document arrows reject `WRONG_DOCUMENT` with endpoint role; navigation invalidates without reinjection. Inherited-origin `about:blank` remains covered by accepted PW-04. +- CSP mechanism: a temporary `securitypolicyviolation` listener observes only the package asset requests during loading and is removed on success or rollback. It does not execute the payload, relax policy, or enable Playwright CSP bypass. A matching blocked package URL maps to `CSP_BLOCKED`; other loading failures remain `LOAD_FAILED`. +- Supported matrix: current Page and same-origin Frame, including inherited-origin `about:blank`, are supported in Chromium and Firefox. Cross-origin/opaque/detached frames, workers, contexts, element handles, and locators as the session context are unsupported. Cross-document arrows are unsupported. WebKit was not run or documented as supported. +- Commands and exit status: initial `npm run check` exited `0` and the initial matrix exited `0`, 8 passed. Independent review requested production `LOAD_FAILED`, opaque-frame, and policy-probe cleanup fixtures. After adding them, final `npm run check` exited `0`; final `npx playwright test -c playwright.pw06.config.ts` exited `0`, 10 passed across Chromium and Firefox in 12.2s. +- Browser/manual artifacts: ignored `test-results/pw06/`; no snapshot or annotation-presence-only claim. +- Pre-existing failures: none. +- Unverified items and reason: WebKit remains optional and was not run; cross-origin injection and automatic reinjection are explicitly unsupported rather than unverified. +- Reviewer decision: ACCEPT after independent review confirmed production CSP/load classification, opaque-frame rejection, probe cleanup, rollback, and the documented Chromium/Firefox matrix. diff --git a/docs/delivery/evidence/PW-07.md b/docs/delivery/evidence/PW-07.md new file mode 100644 index 0000000..f58a5c8 --- /dev/null +++ b/docs/delivery/evidence/PW-07.md @@ -0,0 +1,11 @@ +# PW-07 evidence + +- Revision/base: `d6fd6e7` with PW-01 through PW-06 accepted. +- Environment: Linux; Node `v26.7.0`; npm `11.19.0`; Playwright/Chromium `1.63.0`; 900×600 viewport; light color scheme; reduced motion. +- Files changed: `examples/playwright/index.html`, `style.css`, and `source.js`; `playwright.pw07.config.ts`; `tests/pw07/`; deterministic guidance in `docs/playwright.md`; and this evidence. +- Acceptance criteria checked: the page exposes explicit checkout review state and plain text explaining both marks; source-authored and injected circles use fixed seeds; the example pins Liberation Sans and waits for `document.fonts.ready`; the test fixes viewport/color/motion, awaits remote reveal completion, refreshes after layout settling, asserts no active document animations, and captures without an arbitrary timeout; two immediate capture buffers are byte-identical. +- Commands and exit status: `npm run check` exited `0`. `npx playwright test -c playwright.pw07.config.ts` exited `0`, 1 Chromium test passed in 4.6s. The example is built by the normal package build in global setup and served as static package content. +- Browser/manual artifacts: `test-results/pw07/capture.png` (ignored, 700×518 capture region). Independent visual review approved: context, total, action, two distinct final circles, pinned-font text, and unclipped content were visible; no mid-animation artifact was present. +- Pre-existing failures: none. +- Unverified items and reason: byte stability is proven on the pinned local Chromium environment, not across operating systems or browser versions. Firefox/WebKit screenshot equivalence is not claimed. +- Reviewer decision: ACCEPT after independent code review re-ran the Chromium recipe successfully and visual review confirmed legible context, both final annotations, and no clipping or transitional artifacts. diff --git a/docs/delivery/evidence/PW-08.md b/docs/delivery/evidence/PW-08.md new file mode 100644 index 0000000..82cb3fa --- /dev/null +++ b/docs/delivery/evidence/PW-08.md @@ -0,0 +1,12 @@ +# PW-08 evidence + +- Revision/base: `d6fd6e7` with PW-01 through PW-07 accepted. +- Environment: Linux; Node `v26.7.0`; npm `11.19.0`; Playwright/Chromium `1.63.0`. +- Files changed: behavior fixture under `examples/playwright/`; packed-consumer setup and browser checks under `tests/pw08/`; `playwright.pw08.config.ts`; behavior guidance in `docs/playwright.md`; and this evidence. +- Acceptance criteria checked: a fresh offline consumer installs the current tarball and dynamically imports its installed `dist/playwright/entry.js`; verification separately asserts original DOM identity, unchanged input layout, native focus and typing, value retention, form submission, native plus Stet accessible description, overlay pointer transparency, session cleanup, and restoration of the foreign `aria-describedby` value. Annotation presence is only a setup assertion, not the pass condition. +- Deliberate failure: the same two annotations appear on `behavior.html?broken=1`, but the fixture reports a broken submit. The independent expected status assertion fails and is captured as detection evidence; `finally` disposes the session, after which overlays are absent and the native description remains. A final explicit broken status assertion ensures the test did not catch an unrelated failure. +- Commands and exit status: `npm run check` exited `0`. `npx playwright test -c playwright.pw08.config.ts` exited `0`: offline tarball `funsaized-stet-0.1.0.tgz` was 741,497 bytes with 300 files; both the passing behavior case and deliberate-failure detection case passed in Chromium (2 tests, 10.5s). +- Browser/manual artifacts: ignored packed consumer and run output under `test-results/pw08/`; no visual-only assertion. +- Pre-existing failures: none. +- Unverified items and reason: this example verifies Chromium on the pinned environment; it makes no product QA, adoption, or cross-browser application claim. Browser compatibility for the helper itself is covered separately by PW-06. +- Reviewer decision: ACCEPT after independent review confirmed the installed packed entry, independent native assertions, deliberate failure detection, and cleanup on both paths. diff --git a/docs/delivery/evidence/SHIP-01.md b/docs/delivery/evidence/SHIP-01.md new file mode 100644 index 0000000..7d0ef6f --- /dev/null +++ b/docs/delivery/evidence/SHIP-01.md @@ -0,0 +1,12 @@ +# SHIP-01 evidence + +- Base: PW-01 through PW-08 accepted. +- Environment: Linux; Node `v26.7.0`; npm `11.19.0`; Vite `7.3.6`; Playwright `1.63.0`. +- Packed consumer: `funsaized-stet-0.1.0.tgz`, 742,187 bytes, 301 files, installed offline under ignored `test-results/shipping/consumer`. +- Boundary: ordinary Vite module imports only. `production.ts` owns intentional Stet imports/copy; `review.ts` owns temporary imports/copy/plan/CSS; `review-boundary.ts` is the sole dynamic importer and uses the literal build-time boolean. Configs pass literal `off`, `preview`, or `local` policies to a validated config helper. No plugin, source rewriting, or runtime flag. +- Builds exercised: production customer; preview customer and `stet-preview`; local customer; disabled; mixed customer and `stet-preview`. All enable manifests and hidden source maps. Vite development servers exercise local-only and mixed-local policies. +- Runtime result: `npx playwright test -c playwright.shipping.config.ts` exited `0`: 18 tests passed in 11.7s across Chromium and Firefox. Production rendered only intentional marks; preview customer/local build rendered none; explicit preview rendered temporary marks; disabled rendered none; mixed customer retained only intentional marks; mixed preview and mixed local development rendered both. Every mode preserved native button behavior. +- Disabled qualification: zero rendered overlays is the disabled guarantee only; no exclusion/privacy claim is inferred from that runtime result. +- Static checks: modified TS/JS/MJS files passed `oxfmt` and `oxlint`; `npm run check` exited `0`. +- Output content, graph, asset, and decoded source-map scans are intentionally deferred to SHIP-02. +- Reviewer decision: ACCEPT after independent review found no SHIP-01 blocker and confirmed all contracted mode/runtime fixtures. diff --git a/docs/delivery/evidence/SHIP-02.md b/docs/delivery/evidence/SHIP-02.md new file mode 100644 index 0000000..673361e --- /dev/null +++ b/docs/delivery/evidence/SHIP-02.md @@ -0,0 +1,10 @@ +# SHIP-02 evidence + +- Base: SHIP-01 accepted; its packed Vite consumer and mode runtime checks are reused. +- Scanner: recursively lists every emitted file, including `.vite/manifest.json`, all reachable chunks/assets, hidden maps, and published maps. It checks distinct production, review module/copy/plan/CSS, and Stet runtime/style needles. Decoded map `sources`, `sourcesContent`, and `names` are checked separately; the always-imported review boundary remains allowed. +- Fresh output: `npm run build --silent && node tests/shipping/prepare.mjs` rebuilt the packed consumer after deleting stale output. Ten builds completed with Vite 7.3.6. Excluded preview, local, and mixed customer cases were each emitted once with hidden maps and once with published external maps. +- Result: `node tests/shipping/inspect-output.mjs` exited `0`. Preview/local customer builds contained no review sentinels, review source-map entries, review manifest graph, or Stet runtime/style needles. Mixed customer builds retained production sentinels and Stet runtime/CSS while excluding every review needle/source/manifest entry. Explicit preview builds retained the expected review chunks, CSS, content, maps, and runtime. +- Artifact inventory: pure excluded builds each contain `index.html`, one entry JS and map, and a manifest, with no CSS/review asset. Mixed customer additionally contains Stet CSS. Explicit preview inventories contain named review JS/CSS chunks and maps. The generated full inventory is printed by the scanner. +- Deliberate failure: a fresh copy of the excluded preview output receives `leak.txt` containing `STET_REVIEW_COPY_SENTINEL`; the scanner rejects it with `deliberately-broken: review content leaked STET_REVIEW_COPY_SENTINEL in leak.txt`, proving the offending asset is named. +- Verification: `npm run check` exited `0`; oxfmt and oxlint ran on the two modified scripts with no reported violation. +- Reviewer decision: ACCEPT after independent review confirmed fresh output, recursive artifact/manifest/map scanning, positive controls, mixed retention, and named deliberate failure detection. diff --git a/docs/delivery/evidence/SHIP-03.md b/docs/delivery/evidence/SHIP-03.md new file mode 100644 index 0000000..2d187cf --- /dev/null +++ b/docs/delivery/evidence/SHIP-03.md @@ -0,0 +1,12 @@ +# SHIP-03 evidence + +- Published user recipe: `docs/shipping.md`; indexed from root and documentation READMEs. +- Published agent rule: `agent/skills/stet/references/shipping.md`; linked from the base Stet skill. +- Claim-to-test map: + - literal `off`/`preview`/`local` policy and sole dynamic boundary → `tests/shipping/fixture/config-base.mjs`, config files, and `src/stet/review-boundary.ts`; + - production/preview/local/disabled/mixed rendering and native behavior → `tests/shipping/runtime.spec.ts` in Chromium and Firefox; + - packed-package runtime/content exclusion, mixed retention, complete manifests/chunks/assets, hidden and published decoded maps → `tests/shipping/prepare.mjs` and `inspect-output.mjs`; + - named failure evidence → deliberate `leak.txt` case in `inspect-output.mjs`. +- Scope language: docs explicitly reject environment-flag-only/runtime-only checks, no-op/hidden exclusion claims, source rewriting, and an unproven plugin. Disabled, runtime, and content guarantees are separate. Other bundlers, SSR, copy/deployment tooling, and pipelines require equivalent evidence. +- Verification: `npm run test:agent` exited `0` (9 files, 64 tests); `npm run test:templates` exited `0` (40 snippets, zero Svelte diagnostics); `node tests/shipping/inspect-output.mjs` exited `0` and still identified the deliberate leaking asset. +- Reviewer decision: ACCEPT after independent review; an initial request to clarify the source-map boundary exception, app-owned paths, and disabled scope was corrected and re-reviewed. diff --git a/docs/delivery/tasks/AD-ANGULAR.md b/docs/delivery/tasks/AD-ANGULAR.md index 4962b09..6146fde 100644 --- a/docs/delivery/tasks/AD-ANGULAR.md +++ b/docs/delivery/tasks/AD-ANGULAR.md @@ -1,12 +1,12 @@ # AD-ANGULAR — Angular full control ## Outcome -Angular directives implement the adapter contract. **Parent:** B7. **State:** READY. +Angular directives implement the adapter contract. **Parent:** B7. **State:** ACCEPTED. ## Prerequisites CONTRACT-02, CORE-06, CORE-07 accepted. ## Required reading Adapter contract, `src/angular.ts`, generated Angular examples for reference, and pattern lifecycle/SSR tests. ## Allowed edits -`src/angular.ts` and directly relevant Angular pattern tests only. AGENT-02 owns shared generator sources. +`src/angular.ts`, `tests/angular.test.ts`, and directly relevant Angular pattern tests only. AGENT-02 owns shared generator sources. The integrator approved `tests/angular.test.ts` on 2026-09-13 to provide executable directive coverage. ## Exact change Preserve render-safe attachment while adding handle notification, changed visibility, updates, and replacement. ## Acceptance criteria diff --git a/docs/delivery/tasks/AD-INTEGRATE.md b/docs/delivery/tasks/AD-INTEGRATE.md index 3623afb..1d89714 100644 --- a/docs/delivery/tasks/AD-INTEGRATE.md +++ b/docs/delivery/tasks/AD-INTEGRATE.md @@ -1,6 +1,6 @@ # AD-INTEGRATE — Verify cross-framework parity ## Outcome -Vanilla and all adapters expose equal released behavior. **Parent:** B7/F2. **State:** BLOCKED. +Vanilla and all adapters expose equal released behavior. **Parent:** B7/F2. **State:** ACCEPTED. ## Prerequisites All AD tasks accepted. ## Required reading diff --git a/docs/delivery/tasks/AD-REACT.md b/docs/delivery/tasks/AD-REACT.md index 7af80bf..f86702e 100644 --- a/docs/delivery/tasks/AD-REACT.md +++ b/docs/delivery/tasks/AD-REACT.md @@ -1,6 +1,6 @@ # AD-REACT — React full control ## Outcome -React implements the adapter contract. **Parent:** B7. **State:** READY. +React implements the adapter contract. **Parent:** B7. **State:** ACCEPTED. ## Prerequisites CONTRACT-02, CORE-06, CORE-07 accepted. ## Required reading diff --git a/docs/delivery/tasks/AD-SVELTE.md b/docs/delivery/tasks/AD-SVELTE.md index 9a7e0d7..e6ef6d3 100644 --- a/docs/delivery/tasks/AD-SVELTE.md +++ b/docs/delivery/tasks/AD-SVELTE.md @@ -1,6 +1,6 @@ # AD-SVELTE — Svelte full control ## Outcome -Svelte actions implement the adapter contract. **Parent:** B7. **State:** READY. +Svelte actions implement the adapter contract. **Parent:** B7. **State:** ACCEPTED. ## Prerequisites CONTRACT-02, CORE-06, CORE-07 accepted. ## Required reading diff --git a/docs/delivery/tasks/AD-VUE.md b/docs/delivery/tasks/AD-VUE.md index fe49d15..bbb7671 100644 --- a/docs/delivery/tasks/AD-VUE.md +++ b/docs/delivery/tasks/AD-VUE.md @@ -1,6 +1,6 @@ # AD-VUE — Vue full control ## Outcome -Vue directives implement the adapter contract. **Parent:** B7. **State:** READY. +Vue directives implement the adapter contract. **Parent:** B7. **State:** ACCEPTED. ## Prerequisites CONTRACT-02, CORE-06, CORE-07 accepted. ## Required reading diff --git a/docs/delivery/tasks/AGENT-01.md b/docs/delivery/tasks/AGENT-01.md index 3b3c595..ea8395c 100644 --- a/docs/delivery/tasks/AGENT-01.md +++ b/docs/delivery/tasks/AGENT-01.md @@ -1,10 +1,14 @@ # AGENT-01 — Keep generated contracts current ## Outcome -The complete first-release surface has matching installed agent facts. **Parent:** F1. **State:** BLOCKED/final audit. +The current implemented surface has matching installed agent facts. **Parent:** F1. **State:** ACCEPTED. ## Prerequisites AGENT-02, PW-03 and AGENT-03 accepted. Public tasks still generate their owned artifacts when required; this task audits the integrated result after the final shared generator writer. + +A provisional audit temporarily waived PW-03 and AGENT-03 on 2026-09-13. This +final audit was reopened after both were accepted and supersedes that provisional +decision. ## Required reading `agent/catalog.mjs`, `scripts/agent/generate.mjs`, schema/drift tests and changed API. ## Allowed edits diff --git a/docs/delivery/tasks/AGENT-02.md b/docs/delivery/tasks/AGENT-02.md index 63fca87..dc6c4cc 100644 --- a/docs/delivery/tasks/AGENT-02.md +++ b/docs/delivery/tasks/AGENT-02.md @@ -1,6 +1,6 @@ # AGENT-02 — Add canonical motion recipes ## Outcome -Agents use real static, show, hide, replay and cancellation-aware APIs in every framework. **Parent:** F2. **State:** BLOCKED. +Agents use real static, show, hide, replay and cancellation-aware APIs in every framework. **Parent:** F2. **State:** ACCEPTED. ## Prerequisites AD-INTEGRATE, CORE-05, and BOX-02 accepted. This task is the final planned writer of shared snippet/pattern sources before AGENT-01 audits them. diff --git a/docs/delivery/tasks/AGENT-03.md b/docs/delivery/tasks/AGENT-03.md index feab978..e94c4db 100644 --- a/docs/delivery/tasks/AGENT-03.md +++ b/docs/delivery/tasks/AGENT-03.md @@ -1,6 +1,6 @@ # AGENT-03 — Teach source versus injection ## Outcome -Skills choose delivery by intended lifetime. **Parent:** F3. **State:** BLOCKED. +Skills choose delivery by intended lifetime. **Parent:** F3. **State:** ACCEPTED. ## Prerequisites PW-06 and SHIP-03 accepted. ## Required reading diff --git a/docs/delivery/tasks/BOX-01.md b/docs/delivery/tasks/BOX-01.md index 811ae88..2c29a6c 100644 --- a/docs/delivery/tasks/BOX-01.md +++ b/docs/delivery/tasks/BOX-01.md @@ -1,6 +1,6 @@ # BOX-01 — Add deterministic box geometry ## Outcome -Internal rough-box geometry reuses stroked reveal. **Parent:** C1. **State:** READY. +Internal rough-box geometry reuses stroked reveal. **Parent:** C1. **State:** ACCEPTED. ## Prerequisites CORE-06 accepted, so no concurrent writer is changing `src/rough.ts`. ## Required reading diff --git a/docs/delivery/tasks/BOX-02.md b/docs/delivery/tasks/BOX-02.md index 29fbc1f..7186993 100644 --- a/docs/delivery/tasks/BOX-02.md +++ b/docs/delivery/tasks/BOX-02.md @@ -1,6 +1,6 @@ # BOX-02 — Publish box everywhere ## Outcome -Box is complete across runtime, adapters, agent contracts, templates, and package. **Parent:** C1/F1. **State:** BLOCKED. +Box is complete across runtime, adapters, agent contracts, templates, and package. **Parent:** C1/F1. **State:** ACCEPTED. ## Prerequisites BOX-01 and AD-INTEGRATE accepted. ## Required reading diff --git a/docs/delivery/tasks/CORE-08.md b/docs/delivery/tasks/CORE-08.md index 919c0f1..290d3d3 100644 --- a/docs/delivery/tasks/CORE-08.md +++ b/docs/delivery/tasks/CORE-08.md @@ -3,7 +3,7 @@ ## Outcome Accepted measured budgets after motion, without hidden leakage. ## Parent -A5. **State: BLOCKED.** +A5. **State: ACCEPTED.** ## Prerequisites Accepted MEASURE-01, CORE-06, and BOX-02 so the budget includes every required core primitive. diff --git a/docs/delivery/tasks/PW-01.md b/docs/delivery/tasks/PW-01.md index 37332c6..cc39e42 100644 --- a/docs/delivery/tasks/PW-01.md +++ b/docs/delivery/tasks/PW-01.md @@ -1,6 +1,6 @@ # PW-01 — Prove renderer and CSS loading ## Outcome -Evidence selects a loader; no production helper yet. **Parent:** D1/D2. **State: READY.** +Evidence selects a loader; no production helper yet. **Parent:** D1/D2. **State: ACCEPTED.** ## Prerequisites CONTRACT-03 accepted. ## Required reading diff --git a/docs/delivery/tasks/PW-02.md b/docs/delivery/tasks/PW-02.md index 31ae41b..2d3c1be 100644 --- a/docs/delivery/tasks/PW-02.md +++ b/docs/delivery/tasks/PW-02.md @@ -1,6 +1,6 @@ # PW-02 — Prove payload and navigation lifetime ## Outcome -Packed local payload works without consumer build dependencies. **Parent:** D1/D2/D3. **State:** BLOCKED. +Packed local payload works without consumer build dependencies. **Parent:** D1/D2/D3. **State:** ACCEPTED. ## Prerequisites PW-01 and CORE-05 accepted. ## Required reading diff --git a/docs/delivery/tasks/PW-03.md b/docs/delivery/tasks/PW-03.md index 69dcdee..a00ac3b 100644 --- a/docs/delivery/tasks/PW-03.md +++ b/docs/delivery/tasks/PW-03.md @@ -1,6 +1,6 @@ # PW-03 — Build the optional Playwright entry ## Outcome -`@funsaized/stet/playwright` and its prebuilt local payload resolve from a package. **Parent:** D2. **State:** BLOCKED. +`@funsaized/stet/playwright` and its prebuilt local payload resolve from a package. **Parent:** D2. **State:** ACCEPTED. ## Prerequisites PW-02 accepted. ## Required reading diff --git a/docs/delivery/tasks/PW-04.md b/docs/delivery/tasks/PW-04.md index 85095c9..c476e53 100644 --- a/docs/delivery/tasks/PW-04.md +++ b/docs/delivery/tasks/PW-04.md @@ -1,6 +1,6 @@ # PW-04 — Add sessions and locator attachment ## Outcome -Owned sessions attach supported annotations to one live target. **Parent:** D2/D3. **State:** BLOCKED. +Owned sessions attach supported annotations to one live target. **Parent:** D2/D3. **State:** ACCEPTED. ## Prerequisites PW-03 accepted. ## Required reading diff --git a/docs/delivery/tasks/PW-05.md b/docs/delivery/tasks/PW-05.md index 44e63f3..6241790 100644 --- a/docs/delivery/tasks/PW-05.md +++ b/docs/delivery/tasks/PW-05.md @@ -1,6 +1,6 @@ # PW-05 — Add remote handles and cleanup ## Outcome -Remote lifecycle mirrors runtime results and survives navigation safely. **Parent:** D3. **State:** BLOCKED. +Remote lifecycle mirrors runtime results and survives navigation safely. **Parent:** D3. **State:** ACCEPTED. ## Prerequisites PW-04 and CORE-05 accepted. ## Required reading diff --git a/docs/delivery/tasks/PW-06.md b/docs/delivery/tasks/PW-06.md index 769fccd..dd66a3f 100644 --- a/docs/delivery/tasks/PW-06.md +++ b/docs/delivery/tasks/PW-06.md @@ -1,6 +1,6 @@ # PW-06 — Freeze frame and CSP support ## Outcome -Investigation behavior becomes permanent tests and honest documentation. **Parent:** D1/D3. **State:** BLOCKED. +Investigation behavior becomes permanent tests and honest documentation. **Parent:** D1/D3. **State:** ACCEPTED. ## Prerequisites PW-05 accepted. ## Required reading diff --git a/docs/delivery/tasks/PW-07.md b/docs/delivery/tasks/PW-07.md index 8fc1766..6a9e5fc 100644 --- a/docs/delivery/tasks/PW-07.md +++ b/docs/delivery/tasks/PW-07.md @@ -1,6 +1,6 @@ # PW-07 — Add deterministic screenshot recipe ## Outcome -Source and injected annotations produce reproducible screenshots. **Parent:** D4. **State:** BLOCKED. +Source and injected annotations produce reproducible screenshots. **Parent:** D4. **State:** ACCEPTED. ## Prerequisites PW-05 and CORE-06 accepted. ## Required reading diff --git a/docs/delivery/tasks/PW-08.md b/docs/delivery/tasks/PW-08.md index 3202783..4052990 100644 --- a/docs/delivery/tasks/PW-08.md +++ b/docs/delivery/tasks/PW-08.md @@ -1,6 +1,6 @@ # PW-08 — Add independent behavior verification ## Outcome -An annotated handoff checks native behavior separately. **Parent:** D6. **State:** BLOCKED. +An annotated handoff checks native behavior separately. **Parent:** D6. **State:** ACCEPTED. ## Prerequisites PW-05 accepted. ## Required reading diff --git a/docs/delivery/tasks/SHIP-01.md b/docs/delivery/tasks/SHIP-01.md index 01b5ced..165d1a5 100644 --- a/docs/delivery/tasks/SHIP-01.md +++ b/docs/delivery/tasks/SHIP-01.md @@ -1,6 +1,6 @@ # SHIP-01 — Build explicit exclusion fixtures ## Outcome -Production, preview, local-only, disabled, and mixed Vite modes use a review-only module boundary. **Parent:** E2. **State: READY.** +Production, preview, local-only, disabled, and mixed Vite modes use a review-only module boundary. **Parent:** E2. **State: ACCEPTED.** ## Prerequisites CONTRACT-04 accepted. ## Required reading diff --git a/docs/delivery/tasks/SHIP-02.md b/docs/delivery/tasks/SHIP-02.md index 49ebf8a..4efb6f2 100644 --- a/docs/delivery/tasks/SHIP-02.md +++ b/docs/delivery/tasks/SHIP-02.md @@ -1,6 +1,6 @@ # SHIP-02 — Inspect excluded output ## Outcome -Output scans falsify runtime/content exclusion claims. **Parent:** E4. **State:** BLOCKED. +Output scans falsify runtime/content exclusion claims. **Parent:** E4. **State:** ACCEPTED. ## Prerequisites SHIP-01 accepted. ## Required reading diff --git a/docs/delivery/tasks/SHIP-03.md b/docs/delivery/tasks/SHIP-03.md index 8f9eb0e..3e34cbc 100644 --- a/docs/delivery/tasks/SHIP-03.md +++ b/docs/delivery/tasks/SHIP-03.md @@ -1,6 +1,6 @@ # SHIP-03 — Publish shipping and agent rules ## Outcome -Users and agents implement only tested shipping guarantees. **Parent:** E1/F4. **State:** BLOCKED. +Users and agents implement only tested shipping guarantees. **Parent:** E1/F4. **State:** ACCEPTED. ## Prerequisites SHIP-02 accepted. ## Required reading diff --git a/docs/playwright.md b/docs/playwright.md new file mode 100644 index 0000000..b67ec65 --- /dev/null +++ b/docs/playwright.md @@ -0,0 +1,61 @@ +# Playwright integration + +`@funsaized/stet/playwright` creates temporary Stet annotations from caller-owned +Playwright `Page`, same-origin `Frame`, and `Locator` objects. The optional entry +supports Playwright `>=1.63 <2`; Playwright is not installed or imported at runtime +by Stet. + +```ts +import { createStet } from "@funsaized/stet/playwright"; + +const stet = await createStet(page); +const note = await stet.circle(page.getByRole("button", { name: "Save" })); +await note.show(); +await stet.dispose(); +``` + +The package contains its browser payload and stylesheet. Stet serves those bytes +through same-origin Playwright routes and loads URL-backed script and style tags, +so the page's Content Security Policy remains active. It does not use a CDN, +inline the payload, call `bypassCSP`, or require a consumer bundler. A policy must +permit same-origin `script-src` and `style-src`; otherwise `createStet` rejects +with `CSP_BLOCKED` and an actionable message. Other asset failures use +`LOAD_FAILED`. + +## Supported matrix + +| Context | Chromium | Firefox | +| --- | --- | --- | +| Current Page document | Supported | Supported | +| Same-origin Frame, including inherited-origin `about:blank` | Supported | Supported | +| Cross-origin or opaque sandboxed Frame | Unsupported | Unsupported | +| Locator from another document or cross-document arrow | Rejected | Rejected | + +WebKit is not a first-release support gate and is not claimed. Workers, +browser contexts, element handles, locators passed as the context, detached +frames, and cross-origin frames are unsupported. + +Each locator is resolved once and must match exactly one connected element in the +bound document. Navigation or frame detachment permanently invalidates the +session. Stet does not rediscover targets, cross documents, or reinject after +navigation; create a new session explicitly. `dispose()` removes only that +session's annotations and resources, leaving source-authored Stet and other +sessions untouched. + +## Deterministic screenshots + +Use an explicit viewport and application state, fixed annotation seeds, a pinned +font, and `reducedMotion: "reduce"`. Wait for `document.fonts.ready`, await each +remote `show()` result, call `refresh()` after fonts/layout settle, and assert +`document.getAnimations().length === 0` before capture. This avoids arbitrary +animation sleeps. Keep visible text in the capture explaining what is marked and +why; an image alone is not a review handoff. The runnable recipe is +`examples/playwright/`, verified in pinned Chromium by `playwright.pw07.config.ts`. + +## Verify behavior separately + +Annotations are not evidence that the underlying application works. Assert DOM +identity, layout, focus, typing, submission, accessible descriptions, pointer +behavior, and cleanup independently. Keep `dispose()` in `finally` so a failed +application assertion cannot leave injected resources behind. The packed-consumer +example and deliberate broken-submit check run under `playwright.pw08.config.ts`. diff --git a/docs/product-direction.md b/docs/product-direction.md index c594d10..8a0815f 100644 --- a/docs/product-direction.md +++ b/docs/product-direction.md @@ -279,10 +279,10 @@ exporting the entire test harness. ### 4.6 Size and packaging -The checked-in build measured on 2026-09-12 at approximately 6.83 KiB gzip for the -core JavaScript and 1.11 KiB gzip for CSS. The core budget is currently 7 KiB, so -the proposed functionality cannot be promised within that ceiling without -measurement. +After motion and box landed, the 2026-09-13 build measured 10.36 KiB gzip for the +legacy raw-concatenation core and 1.11 KiB gzip for CSS. The accepted guards are +11 KiB and 3 KiB respectively; consumer bundles are measured separately to keep +tree-shaking visible. The project should preserve tree-shaking and report at least: @@ -918,8 +918,8 @@ external pilot feedback. #### A5. Define size measurements and provisional budgets -- **Problem:** the current core is approximately 6.83 KiB against a 7 KiB budget, - leaving no honest room for the direction. +- **Problem:** the former 7 KiB raw-concatenation guard left no honest room for + the accepted motion runtime and box primitive. - **Proposed change:** report a minimal primitive consumer, full core, CSS, and optional integrations separately; preserve provisional regression limits, then finalize them after the first motion prototype. @@ -933,6 +933,8 @@ external pilot feedback. - **Verification:** extend package bundle fixtures and size script. - **Impact / confidence / effort:** Medium / High / Small. - **Dependencies:** none; final thresholds follow B2. +- **Decision:** CORE-08 set the final raw-concatenation guard to 11 KiB after a + measured 10.36 KiB result; CSS remains capped at 3 KiB. - **Purpose:** scales trusted adoption. #### A6. Clarify install, binary, ESM, CSS, and platform requirements diff --git a/docs/shipping.md b/docs/shipping.md new file mode 100644 index 0000000..903bf56 --- /dev/null +++ b/docs/shipping.md @@ -0,0 +1,95 @@ +# Shipping annotations with Vite + +Stet does not strip annotations from application source. Choose what ships by +putting temporary review annotations behind an explicit module boundary that +Vite can eliminate at build time. Keep intentional production annotations +outside that boundary. + +## Choose a mode + +| Policy | Development server | Customer build | `vite build --mode stet-preview` | +| --- | --- | --- | --- | +| `off` | Review module excluded | Review module excluded | Review module excluded | +| `preview` | Review module excluded | Review module excluded | Review module included | +| `local` | Review module included | Review module excluded | Review module excluded | + +Production annotations import the app-owned `src/stet/production` module directly. +Preview-only and local-only applications call only their app-owned +`src/stet/review-boundary`. Mixed applications do both. These are application +files, not `@funsaized/stet` package exports. A disabled application imports +neither annotation setup; that proves only that no annotation renders, not that +code is absent. + +## Own the boundary + +Put every temporary Stet import, stylesheet, annotation string, plan, and setup +function in `src/stet/review.*`. It must have no top-level DOM work. Make this the +only application import of that module: + +```ts +declare const __STET_REVIEW_BUILD__: boolean; + +export async function mountReviewBoundary(): Promise<() => void> { + if (!__STET_REVIEW_BUILD__) return () => {}; + const { mountReview } = await import("./review"); + return mountReview(); +} +``` + +Set the constant to a literal boolean in Vite configuration. This tested policy +helper rejects unknown values: + +```ts +import { defineConfig } from "vite"; + +const policy = "preview"; // "off" | "preview" | "local" + +export default defineConfig(({ command, mode }) => ({ + define: { + __STET_REVIEW_BUILD__: JSON.stringify( + policy === "preview" + ? mode === "stet-preview" + : policy === "local" + ? command === "serve" + : policy === "off" + ? false + : (() => { + throw new Error(`Unknown Stet review policy: ${policy}`); + })(), + ), + }, +})); +``` + +Call the boundary from client lifecycle code after targets exist. Do not use a +client-readable `VITE_*` environment value, a runtime-only check, a no-op adapter, +or `visible: false` as an exclusion mechanism. An environment variable alone does +not select the explicit preview build. No plugin or source rewriting is required. + +## Verify emitted output + +Build from the packed package used by customers. Enable `build.manifest`, delete +the old output first, and inspect every emitted JavaScript, CSS, asset, manifest +entry, reachable dynamic chunk, and source map. Test excluded builds with hidden +maps and with published external or inline maps; decode `sources`, +`sourcesContent`, and `names` rather than searching only the entry chunk. The +always-imported `review-boundary.ts`, its `import("./review")` specifier, and raw +source-map mappings may remain. Exclusion applies to `review.*` module paths and +temporary sentinels/content, not to the boundary's name. + +Claim guarantees separately: + +1. **Disabled:** a browser test finds no configured circle or underline overlay + while the fixture's native button click still passes. This alone makes no + exclusion claim. +2. **Runtime excluded:** builds with no retained Stet import contain no Stet JS/CSS + or runtime/style needles, and their manifest has no review chunk. +3. **Content excluded:** temporary module paths, strings, plans, CSS sentinels, and + decoded map content are absent from all output. Mixed builds must still contain + the intentional production sentinels and Stet runtime/CSS. + +The executable Vite fixture is under `tests/shipping/fixture`; its browser matrix +runs with `playwright.shipping.config.ts`, and `tests/shipping/inspect-output.mjs` +implements the artifact checks. Equivalent evidence is required before making +these claims for SSR, another bundler, static-copy tooling, or a deployment +pipeline. diff --git a/examples/playwright/behavior.css b/examples/playwright/behavior.css new file mode 100644 index 0000000..0cff2dd --- /dev/null +++ b/examples/playwright/behavior.css @@ -0,0 +1,4 @@ +body { margin: 0; font: 18px/1.5 sans-serif; } +main { width: 520px; margin: 48px auto; } +form { display: grid; gap: 12px; } +input, button { box-sizing: border-box; min-height: 44px; padding: 10px; font: inherit; } diff --git a/examples/playwright/behavior.html b/examples/playwright/behavior.html new file mode 100644 index 0000000..5969dcb --- /dev/null +++ b/examples/playwright/behavior.html @@ -0,0 +1,22 @@ + + + + + + + + Stet behavior handoff + + +
+

Receipt settings

+
+ + +

Email used for the receipt

+ +
+ Not submitted +
+ + diff --git a/examples/playwright/behavior.js b/examples/playwright/behavior.js new file mode 100644 index 0000000..5061660 --- /dev/null +++ b/examples/playwright/behavior.js @@ -0,0 +1,13 @@ +const form = document.querySelector("#form"); +const email = document.querySelector("#email"); +const submit = document.querySelector("#submit"); +const status = document.querySelector("#status"); + +globalThis.__behaviorNodes = { form, email, submit }; +form.addEventListener("submit", (event) => { + event.preventDefault(); + status.textContent = new URL(location.href).searchParams.has("broken") + ? "Broken fixture did not submit" + : `Submitted ${email.value}`; +}); +document.documentElement.dataset.ready = "true"; diff --git a/examples/playwright/index.html b/examples/playwright/index.html new file mode 100644 index 0000000..c6e1a15 --- /dev/null +++ b/examples/playwright/index.html @@ -0,0 +1,20 @@ + + + + + + + + + Stet Playwright capture + + +
+

Review context: checkout handoff

+

Confirm the delivery details

+

The source annotation marks the total. The temporary test annotation marks the action under review.

+
Total
$42.00
+ +
+ + diff --git a/examples/playwright/source.js b/examples/playwright/source.js new file mode 100644 index 0000000..af3bfc2 --- /dev/null +++ b/examples/playwright/source.js @@ -0,0 +1,9 @@ +import { circle } from "/dist/index.js"; + +await document.fonts.ready; +globalThis.sourceStet = circle(document.querySelector("#total"), { + seed: 17, + boil: 0.2, + description: "Total to confirm", +}); +document.documentElement.dataset.state = "source-ready"; diff --git a/examples/playwright/style.css b/examples/playwright/style.css new file mode 100644 index 0000000..7dab4de --- /dev/null +++ b/examples/playwright/style.css @@ -0,0 +1,12 @@ +@font-face { + font-family: "Stet Capture"; + src: url("/tests/browser/fonts/LiberationSans-Regular.ttf") format("truetype"); +} + +html { background: #e9e2d0; } +body { margin: 0; color: #202018; font: 18px/1.5 "Stet Capture", sans-serif; } +#capture { box-sizing: border-box; width: 720px; min-height: 460px; margin: 48px auto; padding: 56px; background: #fffdf5; } +.eyebrow { color: #76552c; text-transform: uppercase; letter-spacing: .08em; } +h1 { font-size: 36px; } +dl { display: flex; justify-content: space-between; margin: 60px 0; font-size: 24px; } +button { border: 0; padding: 14px 24px; background: #153f6f; color: white; font: inherit; } diff --git a/package-lock.json b/package-lock.json index d444929..1e9f3a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ }, "peerDependencies": { "@angular/core": ">=20 <22", + "@playwright/test": ">=1.63 <2", "react": ">=18 <20", "vue": ">=3 <4" }, @@ -41,6 +42,9 @@ "@angular/core": { "optional": true }, + "@playwright/test": { + "optional": true + }, "react": { "optional": true }, diff --git a/package.json b/package.json index 53fb5a4..0375ada 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,10 @@ "types": "./dist/angular.d.ts", "import": "./dist/angular.js" }, + "./playwright": { + "types": "./dist/playwright/entry.d.ts", + "import": "./dist/playwright/entry.js" + }, "./style.css": { "types": "./style.css.d.ts", "default": "./style.css" @@ -79,7 +83,7 @@ "./agent/schemas/*": "./agent/schemas/*" }, "scripts": { - "build": "tsc -p tsconfig.json && ngc -p tsconfig.angular.json && npm run agent:generate", + "build": "tsc -p tsconfig.json && ngc -p tsconfig.angular.json && node scripts/build-playwright.mjs && npm run agent:generate", "check": "tsc -p tsconfig.json --noEmit && ngc -p tsconfig.angular.json --noEmit && npm run agent:check", "test": "vitest run", "test:browser": "npm run build && playwright test", @@ -98,6 +102,7 @@ }, "peerDependencies": { "@angular/core": ">=20 <22", + "@playwright/test": ">=1.63 <2", "react": ">=18 <20", "vue": ">=3 <4" }, @@ -105,6 +110,9 @@ "@angular/core": { "optional": true }, + "@playwright/test": { + "optional": true + }, "react": { "optional": true }, diff --git a/playwright.pw01.config.ts b/playwright.pw01.config.ts new file mode 100644 index 0000000..bc6051d --- /dev/null +++ b/playwright.pw01.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "@playwright/test"; + +// PW-01 investigation only. This config is not wired into package scripts and +// does not describe a production helper. Final packaging is PW-02. +export default defineConfig({ + testDir: "tests/pw01", + outputDir: "test-results/pw01/runs", + globalSetup: "./tests/pw01/global-setup.ts", + use: { baseURL: "http://127.0.0.1:4180", viewport: { width: 1000, height: 800 } }, + webServer: { + command: "python -m http.server 4180 --bind 127.0.0.1", + url: "http://127.0.0.1:4180", + reuseExistingServer: !process.env.CI, + }, + projects: [ + { name: "chromium", use: { browserName: "chromium" } }, + { name: "firefox", use: { browserName: "firefox" } }, + ...(process.env.STET_WEBKIT ? [{ name: "webkit", use: { browserName: "webkit" as const } }] : []), + ], +}); diff --git a/playwright.pw02.config.ts b/playwright.pw02.config.ts new file mode 100644 index 0000000..574be7c --- /dev/null +++ b/playwright.pw02.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "@playwright/test"; + +// PW-02 investigation only. This config is not wired into package scripts and +// describes no production helper. It proves the packed-package payload boundary. +export default defineConfig({ + testDir: "tests/pw02", + outputDir: "test-results/pw02/runs", + globalSetup: "./tests/pw02/global-setup.ts", + use: { baseURL: "http://127.0.0.1:4182", viewport: { width: 1000, height: 800 } }, + webServer: { + command: "python -m http.server 4182 --bind 127.0.0.1", + url: "http://127.0.0.1:4182", + reuseExistingServer: !process.env.CI, + }, + projects: [ + { name: "chromium", use: { browserName: "chromium" } }, + { name: "firefox", use: { browserName: "firefox" } }, + ...(process.env.STET_WEBKIT ? [{ name: "webkit", use: { browserName: "webkit" as const } }] : []), + ], +}); diff --git a/playwright.pw04.config.ts b/playwright.pw04.config.ts new file mode 100644 index 0000000..6ca21dd --- /dev/null +++ b/playwright.pw04.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "tests/pw04", + outputDir: "test-results/pw04", + globalSetup: "./tests/pw04/global-setup.ts", + use: { baseURL: "http://127.0.0.1:4184", viewport: { width: 1000, height: 800 } }, + webServer: { + command: "python -m http.server 4184 --bind 0.0.0.0", + url: "http://127.0.0.1:4184", + reuseExistingServer: !process.env.CI, + }, + projects: [ + { name: "chromium", use: { browserName: "chromium" } }, + { name: "firefox", use: { browserName: "firefox" } }, + ...(process.env.STET_WEBKIT ? [{ name: "webkit", use: { browserName: "webkit" as const } }] : []), + ], +}); diff --git a/playwright.pw05.config.ts b/playwright.pw05.config.ts new file mode 100644 index 0000000..d85a4e5 --- /dev/null +++ b/playwright.pw05.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "tests/pw05", + outputDir: "test-results/pw05", + globalSetup: "./tests/pw05/global-setup.ts", + use: { baseURL: "http://127.0.0.1:4185", viewport: { width: 900, height: 700 } }, + webServer: { + command: "python -m http.server 4185 --bind 127.0.0.1", + url: "http://127.0.0.1:4185", + reuseExistingServer: !process.env.CI, + }, + projects: [ + { name: "chromium", use: { browserName: "chromium" } }, + { name: "firefox", use: { browserName: "firefox" } }, + ...(process.env.STET_WEBKIT ? [{ name: "webkit", use: { browserName: "webkit" as const } }] : []), + ], +}); diff --git a/playwright.pw06.config.ts b/playwright.pw06.config.ts new file mode 100644 index 0000000..9e22218 --- /dev/null +++ b/playwright.pw06.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "tests/pw06", + outputDir: "test-results/pw06", + globalSetup: "./tests/pw06/global-setup.ts", + use: { baseURL: "http://127.0.0.1:4186", viewport: { width: 900, height: 700 } }, + webServer: { + command: "python -m http.server 4186 --bind 0.0.0.0", + url: "http://127.0.0.1:4186", + reuseExistingServer: !process.env.CI, + }, + projects: [ + { name: "chromium", use: { browserName: "chromium" } }, + { name: "firefox", use: { browserName: "firefox" } }, + ...(process.env.STET_WEBKIT ? [{ name: "webkit", use: { browserName: "webkit" as const } }] : []), + ], +}); diff --git a/playwright.pw07.config.ts b/playwright.pw07.config.ts new file mode 100644 index 0000000..4db3091 --- /dev/null +++ b/playwright.pw07.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "tests/pw07", + outputDir: "test-results/pw07/runs", + globalSetup: "./tests/pw07/global-setup.ts", + use: { + baseURL: "http://127.0.0.1:4187", + viewport: { width: 900, height: 600 }, + colorScheme: "light", + reducedMotion: "reduce", + }, + webServer: { + command: "python -m http.server 4187 --bind 127.0.0.1", + url: "http://127.0.0.1:4187", + reuseExistingServer: !process.env.CI, + }, + projects: [{ name: "chromium", use: { browserName: "chromium" } }], +}); diff --git a/playwright.pw08.config.ts b/playwright.pw08.config.ts new file mode 100644 index 0000000..be1dd51 --- /dev/null +++ b/playwright.pw08.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "tests/pw08", + outputDir: "test-results/pw08/runs", + globalSetup: "./tests/pw08/global-setup.ts", + use: { baseURL: "http://127.0.0.1:4188", viewport: { width: 900, height: 700 } }, + webServer: { + command: "python -m http.server 4188 --bind 127.0.0.1", + url: "http://127.0.0.1:4188", + reuseExistingServer: !process.env.CI, + }, + projects: [{ name: "chromium", use: { browserName: "chromium" } }], +}); diff --git a/playwright.shipping.config.ts b/playwright.shipping.config.ts new file mode 100644 index 0000000..02f4e8b --- /dev/null +++ b/playwright.shipping.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from "@playwright/test"; + +const vite = "node_modules/vite/bin/vite.js"; +const consumer = "test-results/shipping/consumer"; + +export default defineConfig({ + testDir: "tests/shipping", + testMatch: "runtime.spec.ts", + outputDir: "test-results/shipping/runs", + projects: [ + { name: "chromium", use: { browserName: "chromium" } }, + { name: "firefox", use: { browserName: "firefox" } }, + ...(process.env.STET_WEBKIT + ? [{ name: "webkit", use: { browserName: "webkit" as const } }] + : []), + ], + webServer: [ + { + command: `npm run build --silent && node tests/shipping/prepare.mjs && python -m http.server 4300 --bind 127.0.0.1 --directory ${consumer}/builds`, + url: "http://127.0.0.1:4300/production/", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + { + command: `node ${vite} --config ${consumer}/vite.local.config.mjs --host 127.0.0.1 --port 4301 --strictPort`, + url: "http://127.0.0.1:4301", + reuseExistingServer: !process.env.CI, + }, + { + command: `node ${vite} --config ${consumer}/vite.mixed-local.config.mjs --host 127.0.0.1 --port 4302 --strictPort`, + url: "http://127.0.0.1:4302", + reuseExistingServer: !process.env.CI, + }, + ], +}); diff --git a/scripts-size.mjs b/scripts-size.mjs index b19ed19..adb0e32 100644 --- a/scripts-size.mjs +++ b/scripts-size.mjs @@ -3,8 +3,8 @@ import { gzipSync } from "node:zlib"; import { fileURLToPath } from "node:url"; import { build } from "esbuild"; -// Legacy raw-concatenation baselines. Kept byte-for-byte comparable with the -// 7 KiB core / 3 KiB CSS guards from 0.0.1; do not fold bundle output in here. +// Legacy raw concatenation remains comparable with the original metric. +// The post-motion/box core ceiling is 11 KiB; CSS retains its 3 KiB guard. const core = ["prng", "rough", "mount", "primitives", "index"] .map((name) => readFileSync(new URL(`dist/${name}.js`, import.meta.url))) .join("\n"); @@ -78,4 +78,4 @@ console.log("browser bundles (esbuild ESM, minified gzip):"); for (const [label, bytes] of Object.entries(bundles)) console.log(`${label}: ${kb(bytes)} KB (${bytes} B)`); -if (sizes.core > 7 * 1024 || sizes.css > 3 * 1024) process.exitCode = 1; +if (sizes.core > 11 * 1024 || sizes.css > 3 * 1024) process.exitCode = 1; diff --git a/scripts/agent/check-package.mjs b/scripts/agent/check-package.mjs index aaf4cbf..3d3e7a4 100644 --- a/scripts/agent/check-package.mjs +++ b/scripts/agent/check-package.mjs @@ -24,10 +24,15 @@ try { const pkg = JSON.parse(readFileSync("package.json", "utf8")); assert.deepEqual(pkg.peerDependencies, { "@angular/core": ">=20 <22", + "@playwright/test": ">=1.63 <2", react: ">=18 <20", vue: ">=3 <4", }); assert(Object.values(pkg.peerDependenciesMeta).every((meta) => meta.optional === true)); + assert.deepEqual(pkg.exports["./playwright"], { + types: "./dist/playwright/entry.d.ts", + import: "./dist/playwright/entry.js", + }); assert.equal(pkg.dependencies, undefined); assert.deepEqual(pkg.sideEffects, ["./style.css"]); for (const path of [ @@ -43,6 +48,9 @@ try { "agent/schemas/capabilities.schema.json", "style.css", "style.css.d.ts", + "dist/playwright/entry.js", + "dist/playwright/entry.d.ts", + "dist/playwright/payload.js", ]) assert(paths.has(path), `Missing packed ${path}`); for (const name of [ @@ -143,9 +151,11 @@ import * as core from '@funsaized/stet'; import * as actions from '@funsaized/stet/svelte'; import * as directives from '@funsaized/stet/vue'; import { validatePlan } from '@funsaized/stet/agent'; +import { createStet, StetPlaywrightError } from '@funsaized/stet/playwright'; assert.equal(typeof core.circle, 'function'); assert.equal(typeof actions.arrow, 'function'); assert(directives.vStetCircle); +assert.equal(typeof createStet, 'function'); assert.equal(typeof StetPlaywrightError, 'function'); assert.equal(validatePlan({}).ok, false); -for (const subpath of ['', '/react', '/vue', '/svelte', '/angular', '/style.css', '/agent', '/agent/capabilities.json', '/agent/schemas/annotation-plan.schema.json']) assert(import.meta.resolve('@funsaized/stet' + subpath)); +for (const subpath of ['', '/react', '/vue', '/svelte', '/angular', '/playwright', '/style.css', '/agent', '/agent/capabilities.json', '/agent/schemas/annotation-plan.schema.json']) assert(import.meta.resolve('@funsaized/stet' + subpath)); `, ); run(process.execPath, ["smoke.mjs"], { cwd: app }); diff --git a/scripts/agent/check-templates.mjs b/scripts/agent/check-templates.mjs index 000ae0e..d10651c 100644 --- a/scripts/agent/check-templates.mjs +++ b/scripts/agent/check-templates.mjs @@ -1,24 +1,53 @@ -import { mkdtempSync, writeFileSync, readFileSync, readdirSync, mkdirSync, rmSync } from 'node:fs'; -import { resolve, join } from 'node:path'; -import { execFileSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, readFileSync, readdirSync, mkdirSync, rmSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { execFileSync } from "node:child_process"; // Keep temp consumers next to node_modules so peer dependencies resolve normally. -const cwd = mkdtempSync(resolve('.stet-template-check-')); -const compilerOptions = { target: 'ES2022', module: 'ESNext', moduleResolution: 'Bundler', strict: true, skipLibCheck: true, jsx: 'react-jsx', experimentalDecorators: true, useDefineForClassFields: false, noEmit: true, lib: ['ES2022', 'DOM'], types: ['svelte'], paths: { - '@funsaized/stet': [resolve('dist/index.d.ts')], - '@funsaized/stet/*': [resolve('dist/*')], -} }; +const cwd = mkdtempSync(resolve(".stet-template-check-")); +const compilerOptions = { + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + strict: true, + skipLibCheck: true, + jsx: "react-jsx", + experimentalDecorators: true, + useDefineForClassFields: false, + noEmit: true, + lib: ["ES2022", "DOM"], + types: ["svelte"], + paths: { + "@funsaized/stet": [resolve("dist/index.d.ts")], + "@funsaized/stet/*": [resolve("dist/*")], + }, +}; try { - for (const framework of readdirSync('agent/templates')) { + for (const framework of readdirSync("agent/templates")) { mkdirSync(join(cwd, framework)); - for (const file of readdirSync(`agent/templates/${framework}`)) writeFileSync(join(cwd, framework, file), readFileSync(`agent/templates/${framework}/${file}`)); + for (const file of readdirSync(`agent/templates/${framework}`)) + writeFileSync( + join(cwd, framework, file), + readFileSync(`agent/templates/${framework}/${file}`), + ); } - writeFileSync(join(cwd, 'tsconfig.json'), JSON.stringify({ compilerOptions, include: ['vanilla/*.ts', 'react/*.tsx', 'angular/*.ts', 'vue/*.vue', 'svelte/*.svelte'], angularCompilerOptions: { strictTemplates: true, compilationMode: 'partial' } })); - for (const [cmd,args] of [ - ['tsc', ['-p', join(cwd, 'tsconfig.json')]], - ['ngc', ['-p', join(cwd, 'tsconfig.json')]], - ['vue-tsc', ['-p', join(cwd, 'tsconfig.json'), '--noEmit']], - ['svelte-check', ['--workspace', cwd, '--tsconfig', './tsconfig.json']], - ]) execFileSync(resolve(`node_modules/.bin/${cmd}`), args, { stdio: 'inherit' }); - console.log('All 35 framework snippets and lifecycle patterns typecheck, including Angular templates and Vue/Svelte SFCs.'); -} finally { rmSync(cwd, { recursive: true, force: true }); } + writeFileSync( + join(cwd, "tsconfig.json"), + JSON.stringify({ + compilerOptions, + include: ["vanilla/*.ts", "react/*.tsx", "angular/*.ts", "vue/*.vue", "svelte/*.svelte"], + angularCompilerOptions: { strictTemplates: true, compilationMode: "partial" }, + }), + ); + for (const [cmd, args] of [ + ["tsc", ["-p", join(cwd, "tsconfig.json")]], + ["ngc", ["-p", join(cwd, "tsconfig.json")]], + ["vue-tsc", ["-p", join(cwd, "tsconfig.json"), "--noEmit"]], + ["svelte-check", ["--workspace", cwd, "--tsconfig", "./tsconfig.json"]], + ]) + execFileSync(resolve(`node_modules/.bin/${cmd}`), args, { stdio: "inherit" }); + console.log( + "All 40 framework snippets and lifecycle patterns typecheck, including Angular templates and Vue/Svelte SFCs.", + ); +} finally { + rmSync(cwd, { recursive: true, force: true }); +} diff --git a/scripts/agent/generate.mjs b/scripts/agent/generate.mjs index 558697f..f7e901f 100644 --- a/scripts/agent/generate.mjs +++ b/scripts/agent/generate.mjs @@ -1,3 +1,4 @@ +/* oxlint-disable unicorn/no-thenable -- JSON Schema defines `then` as a keyword. */ import ts from "typescript"; import Ajv from "ajv"; import standaloneCode from "ajv/dist/standalone/index.js"; @@ -75,9 +76,9 @@ for (const name of ["StetOptions", "ArrowOptions", "StickyOptions"]) { } optionSchemas[name] = object(properties, required); } -// CORE-02: reveal is supported only by circle/underline. Unsupported primitives +// CORE-02/BOX-02: reveal is supported by box, circle, and underline. Unsupported primitives // reject enabled animation so plan validation agrees with runtime attachment. -const revealSupported = new Set(["circle", "underline"]); +const revealSupported = new Set(["box", "circle", "underline"]); const animationOptions = ["animate", "animationDuration", "animationDelay"]; const withoutAnimation = (schema) => ({ ...schema, diff --git a/scripts/build-playwright.mjs b/scripts/build-playwright.mjs new file mode 100644 index 0000000..2263265 --- /dev/null +++ b/scripts/build-playwright.mjs @@ -0,0 +1,56 @@ +// Production build for the optional Playwright entry. +// +// Bundles the browser core as a classic/IIFE payload into `dist/playwright/` +// beside the tsc-emitted Node entry. The wrapper keeps the esbuild namespace +// function-scoped and hands the page only a symbol, so loading the payload from a +// second session reuses the existing runtime and handle map. +import { createHash } from "node:crypto"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const outdir = `${root}dist/playwright`; +mkdirSync(outdir, { recursive: true }); +rmSync(`${outdir}/entry.mjs`, { force: true }); + +const result = await build({ + entryPoints: [`${root}src/index.ts`], + bundle: true, + format: "iife", + globalName: "__stetPlaywrightBundle", + target: "es2020", + write: false, + legalComments: "none", + metafile: true, +}); + +const inputs = Object.keys(result.metafile.inputs).map((path) => path.replaceAll("\\", "/")); +if (!inputs.every((path) => /(^|\/)src\//.test(path) && !path.includes("node_modules"))) + throw new Error(`payload graph must contain only browser source: ${inputs.join(", ")}`); +if (inputs.some((path) => /node:|@playwright/.test(path))) + throw new Error( + `payload graph leaked Node/Playwright code: ${inputs.filter((path) => /node:|@playwright/.test(path)).join(", ")}`, + ); + +const code = result.outputFiles[0].text; +const wrapped = + `(() => {\n${code}\n` + + `const __stetKey = Symbol.for(${JSON.stringify("funsaized.stet.playwright.payload")});\n` + + `globalThis[__stetKey] = globalThis[__stetKey] || { runtime: __stetPlaywrightBundle, handles: new Map(), next: 1 };\n` + + `})();\n`; +writeFileSync(`${outdir}/payload.js`, wrapped); + +const graph = { inputs: inputs.sort() }; +console.log( + JSON.stringify( + { + ok: true, + payloadBytes: wrapped.length, + payloadSha256: createHash("sha256").update(wrapped).digest("hex"), + payloadInputs: graph.inputs, + }, + null, + 2, + ), +); diff --git a/src/angular.ts b/src/angular.ts index 5efd1bb..e15dd42 100644 --- a/src/angular.ts +++ b/src/angular.ts @@ -9,6 +9,7 @@ import { } from "@angular/core"; import { arrow, + box, circle, highlight, mark, @@ -20,43 +21,175 @@ import { type StetOptions, type StickyOptions, } from "./index.js"; +import { updateHandle, type StetRuntimeOptions } from "./mount.js"; +type OnHandle = (handle: StetHandle | null) => void; + +/** Last reconciled state for one directive host. */ +interface Mounted { + elements: (Element | null)[]; + handle: StetHandle | null; + /** Complete non-visible runtime snapshot with adapter metadata stripped. */ + options: Record; + visible: boolean; + /** The callback that received the live handle; it alone receives the null. */ + owner: OnHandle | undefined; +} + +/** + * Complete non-visible runtime snapshot. Every known field is present so a + * later update that drops a value resets it to its default through + * updateHandle. `visible`, `onHandle`, and adapter targets never enter it. + */ +const sharedKeys: readonly (keyof StetOptions)[] = [ + "seed", + "roughness", + "boil", + "stroke", + "fill", + "width", + "resketchOnHover", + "padding", + "description", + "animate", + "animationDuration", + "animationDelay", +]; + +function sharedOptions(value: StetOptions): Record { + const options: Record = {}; + for (const key of sharedKeys) options[key] = value[key]; + return options; +} + +function sameOptions(a: Record, b: Record): boolean { + return Object.keys(a).every((key) => a[key] === b[key]); +} + +/** + * Shared directive lifecycle for every Angular primitive. Applies the adapter + * contract's ordered algorithm on each change: target replacement first, then + * nonreplaying updates, then changed-only visibility, then callback transfer. + * + * `stetOnHandle` is an inherited separate input so the primary options input + * stays serializable. Attachment stays deferred to `afterNextRender` so server + * rendering never touches the DOM and hydration attaches only on the client. + */ @Directive() -abstract class StetDirective implements OnChanges, OnDestroy { - protected handle?: StetHandle; +abstract class StetDirective implements OnChanges, OnDestroy { protected abstract options: T; protected readonly elementRef = inject>(ElementRef); - private renderedOptions?: T; + @Input() stetOnHandle?: OnHandle; + private mounted?: Mounted; + private rendered = false; constructor() { - afterNextRender(() => this.render()); + afterNextRender(() => { + this.rendered = true; + this.reconcile(); + }); } - protected abstract attach(element: Element, options: T): StetHandle; + /** Required targets; null entries are unresolved. */ + protected targets(_value: T): (Element | null)[] { + return [this.elementRef.nativeElement]; + } + + /** Complete runtime snapshot excluding visibility and adapter metadata. */ + protected snapshot(value: T): Record { + return sharedOptions(value); + } + + protected abstract attach(targets: Element[], options: StetRuntimeOptions): StetHandle; ngOnChanges(): void { - if (this.handle && !sameOptions(this.options, this.renderedOptions)) this.render(); + if (this.rendered) this.reconcile(); } ngOnDestroy(): void { - this.handle?.destroy(); + const current = this.mounted; + this.mounted = undefined; + if (!current?.handle) return; + const owner = current.owner; + current.handle.destroy(); + owner?.(null); } - private render(): void { - this.handle?.destroy(); - this.handle = this.attach(this.elementRef.nativeElement, this.options); - this.renderedOptions = { ...this.options }; - } -} + private reconcile(): void { + const value = (this.options ?? {}) as T; + const elements = this.targets(value); + const options = this.snapshot(value); + const visible = value.visible ?? true; + const onHandle = this.stetOnHandle; + const missing = elements.some((target) => target === null); + const previous = this.mounted; + + // No live handle: a missing target is a silent no-op; otherwise attach and + // deliver the handle to the current callback without a preceding null. + if (!previous || !previous.handle) { + if (missing) return; + const handle = this.attach(elements as Element[], { ...options, visible }); + // Commit directive state before invoking user callbacks. + this.mounted = { elements, handle, options, visible, owner: onHandle }; + onHandle?.(handle); + return; + } + + const replaced = + elements.length !== previous.elements.length || + elements.some((target, index) => target !== previous.elements[index]); -function sameOptions(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (!a || !b || typeof a !== "object" || typeof b !== "object") return false; - const keys = Object.keys(a); - return ( - keys.length === Object.keys(b).length && - keys.every((key) => Reflect.get(a, key) === Reflect.get(b, key)) - ); + // Target replacement or a missing target destroys first, sends null to the + // callback that owned the handle, then reattaches only if all targets exist. + // A throwing outgoing callback is surfaced but must not abort a valid + // replacement, so its failure is captured until the new handle is committed. + if (missing || replaced) { + const owner = previous.owner; + previous.handle.destroy(); + this.mounted = { elements, handle: null, options, visible, owner: undefined }; + let outgoing: unknown; + let failed = false; + try { + owner?.(null); + } catch (error) { + failed = true; + outgoing = error; + } + if (!missing) { + const handle = this.attach(elements as Element[], { ...options, visible }); + this.mounted = { elements, handle, options, visible, owner: onHandle }; + onHandle?.(handle); + } + if (failed) throw outgoing; + return; + } + + // Same targets and live handle: update nonreplaying runtime data in place. + if (!sameOptions(options, previous.options)) { + updateHandle(previous.handle, options); + previous.options = options; + } + + // Declarative visibility only acts on a change from the last declarative + // value, so an unrelated update cannot undo an imperative show/hide. + if (visible !== previous.visible) { + previous.visible = visible; + if (visible) void previous.handle.show(); + else previous.handle.hide(); + } + + // Transfer the callback without update, remount, or replay. `finally` + // notifies the new owner even when the outgoing callback throws. + if (onHandle !== previous.owner) { + const owner = previous.owner; + previous.owner = onHandle; + try { + owner?.(null); + } finally { + onHandle?.(previous.handle); + } + } + } } @Directive({ selector: "[stetCircle]", standalone: true }) @@ -65,7 +198,26 @@ export class StetCircleDirective extends StetDirective { @Input() set stetCircle(value: StetOptions | "") { this.options = value || {}; } - protected attach = circle; + override ngOnChanges(): void { + super.ngOnChanges(); + } + protected attach([element]: Element[], options: StetRuntimeOptions): StetHandle { + return circle(element, options as StetOptions); + } +} + +@Directive({ selector: "[stetBox]", standalone: true }) +export class StetBoxDirective extends StetDirective { + protected options: StetOptions = {}; + @Input() set stetBox(value: StetOptions | "") { + this.options = value || {}; + } + override ngOnChanges(): void { + super.ngOnChanges(); + } + protected attach([element]: Element[], options: StetRuntimeOptions): StetHandle { + return box(element, options as StetOptions); + } } @Directive({ selector: "[stetUnderline]", standalone: true }) @@ -74,7 +226,12 @@ export class StetUnderlineDirective extends StetDirective { @Input() set stetUnderline(value: StetOptions | "") { this.options = value || {}; } - protected attach = underline; + override ngOnChanges(): void { + super.ngOnChanges(); + } + protected attach([element]: Element[], options: StetRuntimeOptions): StetHandle { + return underline(element, options as StetOptions); + } } @Directive({ selector: "[stetHighlight]", standalone: true }) @@ -83,7 +240,12 @@ export class StetHighlightDirective extends StetDirective { @Input() set stetHighlight(value: StetOptions | "") { this.options = value || {}; } - protected attach = highlight; + override ngOnChanges(): void { + super.ngOnChanges(); + } + protected attach([element]: Element[], options: StetRuntimeOptions): StetHandle { + return highlight(element, options as StetOptions); + } } @Directive({ selector: "[stetSticky]", standalone: true }) @@ -92,7 +254,21 @@ export class StetStickyDirective extends StetDirective { @Input() set stetSticky(value: StickyOptions) { this.options = value; } - protected attach = sticky; + override ngOnChanges(): void { + super.ngOnChanges(); + } + protected override snapshot(value: StickyOptions): Record { + return { + ...sharedOptions(value), + text: value.text, + side: value.side, + offsetX: value.offsetX, + offsetY: value.offsetY, + }; + } + protected attach([element]: Element[], options: StetRuntimeOptions): StetHandle { + return sticky(element, options as StickyOptions); + } } @Directive({ selector: "[stetMark]", standalone: true }) @@ -101,24 +277,44 @@ export class StetMarkDirective extends StetDirective { + return { ...sharedOptions(value), kind: value.kind }; + } + protected attach([element]: Element[], options: StetRuntimeOptions): StetHandle { + const { kind, ...rest } = options as StetOptions & { kind: MarkKind }; + return mark(element, kind, rest); } } @Directive({ selector: "[stetArrow]", standalone: true }) -export class StetArrowDirective extends StetDirective { - protected options!: ArrowOptions & { to: Element }; - @Input() set stetArrow(value: ArrowOptions & { to: Element }) { +export class StetArrowDirective extends StetDirective { + protected options!: ArrowOptions & { to?: Element | null }; + @Input() set stetArrow(value: ArrowOptions & { to?: Element | null }) { this.options = value; } - protected attach( - element: Element, - { to, ...options }: ArrowOptions & { to: Element }, - ): StetHandle { - return arrow(element, to, options); + override ngOnChanges(): void { + super.ngOnChanges(); + } + // An absent destination is as unresolved as a null one: never pass either to + // the primitive, and wait silently until an Element appears. + protected override targets(value: ArrowOptions & { to?: Element | null }): (Element | null)[] { + return [this.elementRef.nativeElement, value.to ?? null]; + } + protected override snapshot( + value: ArrowOptions & { to?: Element | null }, + ): Record { + return { + ...sharedOptions(value), + label: value.label, + curvature: value.curvature, + labelOffsetX: value.labelOffsetX, + labelOffsetY: value.labelOffsetY, + }; + } + protected attach([element, to]: Element[], options: StetRuntimeOptions): StetHandle { + return arrow(element, to, options as ArrowOptions); } } diff --git a/src/index.ts b/src/index.ts index c87eef4..b6aebfd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export { arrow, circle, highlight, mark, sticky, underline } from "./primitives.js"; +export { arrow, box, circle, highlight, mark, sticky, underline } from "./primitives.js"; export type { ArrowOptions, MarkKind, diff --git a/src/mount.ts b/src/mount.ts index dbadf07..f494298 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -105,11 +105,11 @@ export function updateHandle(handle: StetHandle, nextOptions: StetRuntimeOptions const SVG_NS = "http://www.w3.org/2000/svg"; // CORE-02 reveal support; BOX-02 adds "box" when that primitive becomes public. -const revealSupported = new Set(["circle", "underline"]); +const revealSupported = new Set(["box", "circle", "underline"]); // CORE-03/CORE-04 path-length draw-on shared by every stroked primitive; the // mechanism stays one treatment rather than a per-primitive controller. -const strokedRevealPrimitives = new Set(["circle", "underline"]); +const strokedRevealPrimitives = new Set(["box", "circle", "underline"]); export interface ResolvedAnimation { enabled: boolean; diff --git a/src/playwright/entry.ts b/src/playwright/entry.ts new file mode 100644 index 0000000..6021e0c --- /dev/null +++ b/src/playwright/entry.ts @@ -0,0 +1,580 @@ +// Optional Node-side Playwright entry (`@funsaized/stet/playwright`). +// +// Mechanism frozen by PW-01/PW-02: Stet's build emits a classic/IIFE browser +// payload beside this entry. The entry discovers the payload and the root +// stylesheet relative to its own installed `import.meta.url`, reads them +// package-locally, and fulfills opaque same-origin URLs through the caller's +// Playwright browser context. The bound Page or same-origin Frame loads those +// URLs with `addScriptTag({ url })` / `addStyleTag({ url })`, so browser CSP +// stays active. No CDN, no consumer bundler, and no runtime Playwright import: +// callers pass their own Playwright objects. +// +// This file owns loading, package boundaries, and the remote session scaffold. +// Locator resolution depth and remote lifecycle semantics are gated by PW-04 +// and PW-05; the public signatures here match the approved contract. +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { ElementHandle, Frame, Locator, Page } from "@playwright/test"; +import type { + ArrowOptions, + MarkKind, + StetAnimationResult, + StetOptions, + StickyOptions, +} from "../index.js"; + +export type StetPlaywrightErrorCode = + | "UNSUPPORTED_CONTEXT" + | "FRAME_DETACHED" + | "TARGET_MISSING" + | "TARGET_AMBIGUOUS" + | "TARGET_DETACHED" + | "WRONG_DOCUMENT" + | "NAVIGATION" + | "PAGE_CLOSED" + | "SESSION_DISPOSED" + | "LOAD_FAILED" + | "CSP_BLOCKED" + | "INVALID_OPTIONS" + | "UNSUPPORTED_ANIMATION" + | "BROWSER_OPERATION"; + +export type StetPrimitive = "circle" | "underline" | "highlight" | "arrow" | "sticky" | "mark"; +export type StetLocatorRole = "target" | "from" | "to"; + +export interface StetPlaywrightErrorOptions { + cause?: unknown; + primitive?: StetPrimitive; + role?: StetLocatorRole; +} + +export class StetPlaywrightError extends Error { + readonly code: StetPlaywrightErrorCode; + readonly primitive?: StetPrimitive; + readonly role?: StetLocatorRole; + + constructor( + code: StetPlaywrightErrorCode, + message: string, + options: StetPlaywrightErrorOptions = {}, + ) { + super(message); + // `Error` options are ES2022; assign the cause directly so the package can + // keep its ES2020 target and still retain causes at runtime. + if (options.cause !== undefined) (this as { cause?: unknown }).cause = options.cause; + this.name = "StetPlaywrightError"; + this.code = code; + this.primitive = options.primitive; + this.role = options.role; + } +} + +export interface RemoteStetHandle { + show(): Promise; + hide(): Promise; + replay(): Promise; + refresh(): Promise; + resketch(seed?: number): Promise; + destroy(): Promise; +} + +export interface StetSession { + circle(target: Locator, options?: StetOptions): Promise; + underline(target: Locator, options?: StetOptions): Promise; + highlight(target: Locator, options?: StetOptions): Promise; + arrow(from: Locator, to: Locator, options?: ArrowOptions): Promise; + sticky(target: Locator, options: StickyOptions): Promise; + mark(target: Locator, kind: MarkKind, options?: StetOptions): Promise; + dispose(): Promise; +} + +// Browser-side shape installed by the generated `payload.js`. Types only; the +// payload file is the authority at runtime. +interface PayloadHandle { + show(): Promise; + hide(): void; + replay(): Promise; + refresh(): void; + resketch(seed?: number): void; + destroy(): void; +} + +interface Payload { + runtime: Record PayloadHandle>; + handles: Map; + next: number; +} + +interface InternalSession { + invalidate(code: StetPlaywrightErrorCode): void; +} + +interface RemoteState { + destroyed: boolean; + operation?: Promise; +} + +const PAYLOAD_FILE = fileURLToPath(new URL("./payload.js", import.meta.url)); +const STYLE_FILE = fileURLToPath(new URL("../../style.css", import.meta.url)); +const PAYLOAD_SYMBOL = "funsaized.stet.playwright.payload"; + +const isPage = (value: unknown): value is Page => + typeof value === "object" && + value !== null && + typeof (value as { mainFrame?: unknown }).mainFrame === "function"; + +const isFrame = (value: unknown): value is Frame => + typeof value === "object" && + value !== null && + typeof (value as { page?: unknown }).page === "function" && + typeof (value as { addScriptTag?: unknown }).addScriptTag === "function"; + +// Shared browser-payload realm per bound frame, not per page: the IIFE +// singleton is installed in one frame's global, so sessions bound to different +// frames never share or delete each other's runtime. The last session bound to +// a frame drops that frame's payload. +const realms = new WeakMap(); + +function classify( + error: unknown, + session: InternalSession, + frame: Frame, + page: Page, + primitive?: StetPrimitive, +): StetPlaywrightError { + const text = String((error as { message?: unknown })?.message ?? error); + let code: StetPlaywrightErrorCode = "BROWSER_OPERATION"; + if (page.isClosed()) code = "PAGE_CLOSED"; + else if (frame.isDetached()) code = "FRAME_DETACHED"; + else if (/execution context was destroyed|most likely because of a navigation|navigat/i.test(text)) + code = "NAVIGATION"; + else if (/page has been closed|context or browser has been closed|browser has been closed/i.test(text)) + code = "PAGE_CLOSED"; + else if (/frame (was |has been )?detached/i.test(text)) code = "FRAME_DETACHED"; + else if (/stet: animation is not supported/i.test(text)) + code = "UNSUPPORTED_ANIMATION"; + else if (/stet: (?:.*must be|sticky text is required|mark kind)/i.test(text)) + code = "INVALID_OPTIONS"; + if (code === "NAVIGATION" || code === "PAGE_CLOSED" || code === "FRAME_DETACHED") + session.invalidate(code); + return new StetPlaywrightError(code, `stet playwright: ${text}`, { cause: error, primitive }); +} + +let sequence = 0; + +/** Create an owned annotation session for a Page or same-origin Frame. */ +export async function createStet(context: Page | Frame): Promise { + const page = isPage(context) ? context : isFrame(context) ? context.page() : null; + if (!page) + throw new StetPlaywrightError( + "UNSUPPORTED_CONTEXT", + "stet playwright: expected a live Page or Frame", + ); + if (page.isClosed()) + throw new StetPlaywrightError("PAGE_CLOSED", "stet playwright: page is closed"); + const frame = isPage(context) ? page.mainFrame() : context; + if (frame.isDetached()) + throw new StetPlaywrightError( + "UNSUPPORTED_CONTEXT", + "stet playwright: frame is detached", + ); + if (frame !== page.mainFrame()) { + try { + const [mainOrigin, frameOrigin] = await Promise.all([ + page.mainFrame().evaluate(() => self.origin), + frame.evaluate(() => self.origin), + ]); + if (frameOrigin === "null" || frameOrigin !== mainOrigin) + throw new StetPlaywrightError( + "UNSUPPORTED_CONTEXT", + "stet playwright: frame must be same-origin with the page", + ); + } catch (error) { + if (error instanceof StetPlaywrightError) throw error; + throw new StetPlaywrightError( + "UNSUPPORTED_CONTEXT", + "stet playwright: frame is not a live same-origin frame", + { cause: error }, + ); + } + } + const browserContext = page.context(); + const id = `s${++sequence}`; + const marker = `/__stet__/${id}/`; + const payloadPath = `${marker}payload.js`; + const stylePath = `${marker}style.css`; + const policyKey = `funsaized.stet.playwright.policy.${id}`; + + // Discovery happens at session creation: both files are located relative to + // the installed entry, never from the repository or a CDN. + let payloadBody: string; + let styleBody: string; + try { + payloadBody = readFileSync(PAYLOAD_FILE, "utf8"); + styleBody = readFileSync(STYLE_FILE, "utf8"); + } catch (error) { + throw new StetPlaywrightError( + "LOAD_FAILED", + "stet playwright: package-local assets are unavailable; verify the installed package contains dist/playwright/payload.js and style.css", + { cause: error }, + ); + } + + let invalidated: StetPlaywrightErrorCode | undefined; + let disposed = false; + let acquired = false; + const injected: ElementHandle[] = []; + const attached = new Map(); + const routes: string[] = []; + let listening = false; + let resolveLifecycle!: (code: StetPlaywrightErrorCode) => void; + const lifecycle = new Promise((resolve) => { + resolveLifecycle = resolve; + }); + + const setInvalidated = (code: StetPlaywrightErrorCode) => { + if (!invalidated || code === "FRAME_DETACHED" || code === "PAGE_CLOSED") invalidated = code; + }; + const releaseDocument = (code: StetPlaywrightErrorCode) => { + setInvalidated(code); + resolveLifecycle(invalidated!); + injected.length = 0; + acquired = false; + realms.delete(frame); + for (const pattern of routes.splice(0)) void browserContext.unroute(pattern).catch(() => {}); + }; + const onNavigated = (navigated: Frame) => { + if (navigated === frame) releaseDocument("NAVIGATION"); + }; + const onDetached = (detached: Frame) => { + if (detached === frame) releaseDocument("FRAME_DETACHED"); + }; + const onClosed = () => releaseDocument("PAGE_CLOSED"); + + const installRoutes = async () => { + await browserContext.route(`**${marker}payload.js`, (route) => + route.fulfill({ contentType: "text/javascript", body: payloadBody }), + ); + routes.push(`**${marker}payload.js`); + await browserContext.route(`**${marker}style.css`, (route) => + route.fulfill({ contentType: "text/css", body: styleBody }), + ); + routes.push(`**${marker}style.css`); + }; + + const installPolicyProbe = () => + frame.evaluate((key) => { + const symbol = Symbol.for(key); + const violations: string[] = []; + const listener = (event: SecurityPolicyViolationEvent) => { + violations.push(event.blockedURI); + }; + document.addEventListener("securitypolicyviolation", listener); + (globalThis as unknown as Record)[symbol] = { violations, listener }; + }, policyKey); + + const clearPolicyProbe = async (): Promise => { + await frame + .evaluate((key) => { + const symbol = Symbol.for(key); + const probe = ( + globalThis as unknown as Record< + symbol, + { listener: (event: SecurityPolicyViolationEvent) => void } | undefined + > + )[symbol]; + if (probe) document.removeEventListener("securitypolicyviolation", probe.listener); + delete (globalThis as unknown as Record)[symbol]; + }, policyKey) + .catch(() => {}); + }; + + const policyBlocked = () => + frame + .evaluate( + ({ key, marker }) => { + const probe = ( + globalThis as unknown as Record + )[Symbol.for(key)]; + return Boolean(probe?.violations.some((uri) => uri.includes(marker))); + }, + { key: policyKey, marker }, + ) + .catch(() => false); + + const cleanup = async () => { + await clearPolicyProbe(); + if (listening) { + listening = false; + page.off("framenavigated", onNavigated); + page.off("framedetached", onDetached); + page.off("close", onClosed); + } + for (const pattern of routes.splice(0)) await browserContext.unroute(pattern).catch(() => {}); + for (const node of injected.splice(0)) { + await node.evaluate((element) => (element as Element).remove()).catch(() => {}); + } + // A rejected addScriptTag/addStyleTag can leave its half-created node behind; + // sweep only this session's marker so foreign resources are never touched. + await frame + .evaluate((needle) => { + for (const node of Array.from( + document.querySelectorAll("script[src],link[href]"), + )) { + const reference = node.getAttribute("src") ?? node.getAttribute("href") ?? ""; + if (reference.includes(needle)) node.remove(); + } + }, marker) + .catch(() => {}); + if (acquired) { + acquired = false; + const remaining = (realms.get(frame) ?? 1) - 1; + if (remaining <= 0) { + realms.delete(frame); + await frame + .evaluate((symbol) => { + delete (globalThis as Record)[Symbol.for(symbol)]; + }, PAYLOAD_SYMBOL) + .catch(() => {}); + } else { + realms.set(frame, remaining); + } + } + }; + + const ensureLive = () => { + if (disposed) + throw new StetPlaywrightError("SESSION_DISPOSED", "stet playwright: session disposed"); + if (invalidated) + throw new StetPlaywrightError( + invalidated, + `stet playwright: session invalidated by ${invalidated.toLowerCase()}`, + ); + }; + + const classifyOperation = async ( + error: unknown, + primitive?: StetPrimitive, + ): Promise => { + const text = String((error as { message?: unknown })?.message ?? error); + if ( + frame !== page.mainFrame() && + /execution context was destroyed, most likely because of a navigation/i.test(text) && + !page.isClosed() && + !frame.isDetached() + ) { + const code = await lifecycle; + return new StetPlaywrightError(code, `stet playwright: ${text}`, { + cause: error, + primitive, + }); + } + return classify(error, session, frame, page, primitive); + }; + + const invoke = async ( + handleId: number, + method: keyof PayloadHandle, + argument?: unknown, + ): Promise => { + ensureLive(); + try { + return await frame.evaluate( + (input) => { + const payload = ( + globalThis as unknown as Record + )[Symbol.for(input.symbol)]; + const target = payload?.handles.get(input.id); + if (!target) throw new Error("stet playwright remote handle is unavailable"); + const call = target[input.method] as unknown as (value?: unknown) => unknown; + const result = input.argument === undefined ? call.call(target) : call.call(target, input.argument); + if (input.method === "destroy") payload?.handles.delete(input.id); + return result; + }, + { symbol: PAYLOAD_SYMBOL, id: handleId, method, argument }, + ); + } catch (error) { + throw await classifyOperation(error); + } + }; + + const remote = (handleId: number, state: RemoteState): RemoteStetHandle => { + const operation = (method: "show" | "replay") => { + if (state.destroyed) return Promise.resolve({ status: "cancelled" } as const); + if (method === "show" && state.operation) return state.operation; + const promise = invoke(handleId, method) as Promise; + state.operation = promise; + void promise.then( + () => { + if (state.operation === promise) state.operation = undefined; + }, + () => { + if (state.operation === promise) state.operation = undefined; + }, + ); + return promise; + }; + return { + show: () => operation("show"), + hide: () => state.destroyed ? Promise.resolve() : invoke(handleId, "hide") as Promise, + replay: () => operation("replay"), + refresh: () => state.destroyed ? Promise.resolve() : invoke(handleId, "refresh") as Promise, + resketch: (seed?: number) => state.destroyed ? Promise.resolve() : invoke(handleId, "resketch", seed) as Promise, + async destroy() { + if (state.destroyed) return; + await invoke(handleId, "destroy"); + state.destroyed = true; + attached.delete(handleId); + }, + }; + }; + + const attach = async ( + primitive: StetPrimitive, + locators: Locator[], + args: unknown[], + ): Promise => { + ensureLive(); + const handles: ElementHandle[] = []; + try { + for (let index = 0; index < locators.length; index += 1) { + const role: StetLocatorRole = locators.length > 1 ? (index === 0 ? "from" : "to") : "target"; + const snapshot = await locators[index].elementHandles(); + handles.push(...snapshot); + const owner = snapshot[0] ? await snapshot[0].ownerFrame() : null; + if (owner && owner !== frame) + throw new StetPlaywrightError( + "WRONG_DOCUMENT", + `stet playwright: ${primitive} ${role} belongs to another document`, + { primitive, role }, + ); + if (snapshot.length !== 1) + throw new StetPlaywrightError( + snapshot.length === 0 ? "TARGET_MISSING" : "TARGET_AMBIGUOUS", + `stet playwright: ${primitive} ${role} locator matched ${snapshot.length} elements`, + { primitive, role }, + ); + } + const result = await frame.evaluate( + (input) => { + const payload = ( + globalThis as unknown as Record + )[Symbol.for(input.symbol)]; + if (!payload) throw new Error("stet playwright payload is not loaded"); + const elements = input.elements as unknown as Element[]; + const detached = elements.findIndex( + (element) => !element.isConnected || element.ownerDocument !== document, + ); + if (detached !== -1) return { detached }; + const handle = payload.runtime[input.primitive](...elements, ...input.args); + const id = payload.next++; + payload.handles.set(id, handle); + return { id }; + }, + { symbol: PAYLOAD_SYMBOL, primitive, args, elements: handles }, + ); + if ("detached" in result) { + const role: StetLocatorRole = locators.length > 1 ? (result.detached === 0 ? "from" : "to") : "target"; + throw new StetPlaywrightError( + "TARGET_DETACHED", + `stet playwright: ${primitive} ${role} target detached before attachment`, + { primitive, role }, + ); + } + const handleId = result.id; + const state: RemoteState = { destroyed: false }; + attached.set(handleId, state); + return remote(handleId, state); + } catch (error) { + if (error instanceof StetPlaywrightError) throw error; + throw await classifyOperation(error, primitive); + } finally { + await Promise.all(handles.map((handle) => handle.dispose())); + } + }; + + const session: StetSession & InternalSession = { + invalidate(code) { + setInvalidated(code); + }, + circle(target, options = {}) { + return attach("circle", [target], [options]); + }, + underline(target, options = {}) { + return attach("underline", [target], [options]); + }, + highlight(target, options = {}) { + return attach("highlight", [target], [options]); + }, + arrow(from, to, options = {}) { + return attach("arrow", [from, to], [options]); + }, + sticky(target, options) { + return attach("sticky", [target], [options]); + }, + mark(target, kind, options = {}) { + return attach("mark", [target], [kind, options]); + }, + async dispose() { + if (disposed) return; + disposed = true; + for (const state of attached.values()) state.destroyed = true; + await frame + .evaluate( + (input) => { + const payload = ( + globalThis as unknown as Record + )[Symbol.for(input.symbol)]; + if (!payload) return; + for (const handle of input.ids) { + payload.handles.get(handle)?.destroy(); + payload.handles.delete(handle); + } + }, + { symbol: PAYLOAD_SYMBOL, ids: [...attached.keys()] }, + ) + .catch(() => {}); + attached.clear(); + await cleanup(); + }, + }; + + try { + await installRoutes(); + await installPolicyProbe(); + // Stylesheet first so the first painted frame is styled, then the payload. + injected.push(await frame.addStyleTag({ url: stylePath })); + injected.push(await frame.addScriptTag({ url: payloadPath })); + await frame.waitForFunction( + (symbol) => { + const payload = ( + globalThis as unknown as Record + )[Symbol.for(symbol)]; + return typeof payload?.runtime === "object"; + }, + PAYLOAD_SYMBOL, + { timeout: 5000 }, + ); + await clearPolicyProbe(); + acquired = true; + realms.set(frame, (realms.get(frame) ?? 0) + 1); + listening = true; + page.on("framenavigated", onNavigated); + page.on("framedetached", onDetached); + page.on("close", onClosed); + } catch (error) { + const blocked = await policyBlocked(); + await cleanup(); + throw new StetPlaywrightError( + blocked ? "CSP_BLOCKED" : "LOAD_FAILED", + blocked + ? "stet playwright: page CSP blocked package-local script or style; allow same-origin script-src and style-src for Stet" + : `stet playwright: package-local assets failed to load; verify the installed package and browser context: ${ + (error as { message?: unknown })?.message ?? error + }`, + { cause: error }, + ); + } + + return session; +} diff --git a/src/primitives.ts b/src/primitives.ts index f912150..a241e42 100644 --- a/src/primitives.ts +++ b/src/primitives.ts @@ -14,6 +14,7 @@ import { arrowGeometry, markerWash, roughArrow, + roughBox, roughCheckmark, roughEllipse, roughLine, @@ -112,6 +113,28 @@ export function circle(element: Element, options: StetOptions = {}): StetHandle return single(element, "circle", options); } +export function box(element: Element, options: StetOptions = {}): StetHandle { + assertElement(element); + return mount([element], "box", options, (root, svg, { rough, options: cfg }) => { + const rect = element.getBoundingClientRect(); + const padding = cfg.padding ?? 5; + place( + root, + svg, + rect.left - padding, + rect.top - padding, + rect.width + padding * 2, + rect.height + padding * 2, + ); + createPath( + svg, + "stet-box", + (o) => roughBox(padding, padding, rect.width, rect.height, o), + rough, + ); + }).handle; +} + export function underline(element: Element, options: StetOptions = {}): StetHandle { return single(element, "underline", options); } diff --git a/src/react.ts b/src/react.ts index 2aa5ee6..3d63b03 100644 --- a/src/react.ts +++ b/src/react.ts @@ -3,6 +3,7 @@ import { useEffect, useRef, type RefObject } from "react"; import { arrow, + box, circle, highlight, mark, @@ -14,99 +15,264 @@ import { type StetOptions, type StickyOptions, } from "./index.js"; +import { updateHandle, type StetRuntimeOptions } from "./mount.js"; + +type OnHandle = (handle: StetHandle | null) => void; type TargetProps = T & { target: RefObject; + onHandle?: OnHandle; }; -function useTarget( - target: RefObject, - attach: (element: Element) => StetHandle, - dependencies: readonly unknown[], -): void { - useTargets([target], ([element]) => attach(element), dependencies); +type ArrowProps = ArrowOptions & { + from: RefObject; + to: RefObject; + onHandle?: OnHandle; +}; + +/** Last reconciled state for one annotation instance. */ +interface Mounted { + elements: (Element | null)[]; + handle: StetHandle | null; + options: Record; + visible: boolean; + /** The callback that received the live handle; it alone receives the null. */ + owner: OnHandle | undefined; +} + +const sharedKeys: readonly (keyof StetOptions)[] = [ + "seed", + "roughness", + "boil", + "stroke", + "fill", + "width", + "resketchOnHover", + "padding", + "description", + "animate", + "animationDuration", + "animationDelay", +]; + +/** + * Complete non-visible runtime snapshot. Every known field is present so a + * later render that drops a prop resets it to its default through updateHandle. + * `visible`, `onHandle`, and targets never enter this object. + */ +function sharedOptions(props: StetOptions): Record { + const options: Record = {}; + for (const key of sharedKeys) options[key] = props[key]; + return options; +} + +function stickyOptions(props: StickyOptions): Record { + return { + ...sharedOptions(props), + text: props.text, + side: props.side, + offsetX: props.offsetX, + offsetY: props.offsetY, + }; +} + +function markOptions(props: StetOptions & { kind: MarkKind }): Record { + return { ...sharedOptions(props), kind: props.kind }; +} + +function arrowOptions(props: ArrowOptions): Record { + return { + ...sharedOptions(props), + label: props.label, + curvature: props.curvature, + labelOffsetX: props.labelOffsetX, + labelOffsetY: props.labelOffsetY, + }; } -function useTargets( - refs: RefObject[], - attach: (elements: Element[]) => StetHandle, - dependencies: readonly unknown[], +function sameOptions(a: Record, b: Record): boolean { + return Object.keys(a).every((key) => a[key] === b[key]); +} + +/** + * Shared lifecycle for every React primitive. Runs after each commit so it can + * observe the current refs, options, visibility, and callback, and applies the + * adapter contract's ordered algorithm: target replacement first, then + * nonreplaying updates, then changed-only visibility, then callback transfer. + * + * A no-dependency effect is the React 18-compatible way to reconcile after + * every render while keeping unmount cleanup in a separate effect; refs cannot + * change an effect's dependency list, so DOM replacement under a stable ref + * must be detected here rather than through a dependency array. + */ +function useAnnotation( + refs: readonly RefObject[], + options: Record, + visible: boolean | undefined, + onHandle: OnHandle | undefined, + attach: (elements: Element[], options: StetRuntimeOptions) => StetHandle, ): void { - const mounted = useRef<{ elements: (Element | null)[]; dependencies: readonly unknown[]; handle?: StetHandle } | null>(null); - // Ref identity can stay stable while React replaces the underlying DOM node. + const mounted = useRef(null); + useEffect(() => { const elements = refs.map((ref) => ref.current); + const resolved = visible ?? true; + const missing = elements.some((element) => element === null); const previous = mounted.current; - if (previous && elements.every((element, index) => element === previous.elements[index]) && - dependencies.every((value, index) => Object.is(value, previous.dependencies[index]))) return; - previous?.handle?.destroy(); - mounted.current = { elements, dependencies, - handle: elements.every((element): element is Element => element !== null) ? attach(elements) : undefined }; + + // No live handle: a missing target is a silent no-op; otherwise attach and + // deliver the handle to the current callback without a preceding null. + if (!previous || !previous.handle) { + if (missing) return; + const handle = attach(elements as Element[], { ...options, visible: resolved }); + // Commit adapter state before invoking user callbacks. + mounted.current = { elements, handle, options, visible: resolved, owner: onHandle }; + onHandle?.(handle); + return; + } + + const replaced = + elements.length !== previous.elements.length || + elements.some((element, index) => element !== previous.elements[index]); + + // Target replacement or a missing target destroys first, sends null to the + // callback that owned the handle, then reattaches only if all targets exist. + // A throwing outgoing callback is surfaced but must not abort a valid + // replacement, so its failure is captured until the new handle is committed. + if (missing || replaced) { + const owner = previous.owner; + previous.handle.destroy(); + mounted.current = { elements, handle: null, options, visible: resolved, owner: undefined }; + let outgoing: unknown; + let failed = false; + try { + owner?.(null); + } catch (error) { + failed = true; + outgoing = error; + } + if (!missing) { + const handle = attach(elements as Element[], { ...options, visible: resolved }); + mounted.current = { elements, handle, options, visible: resolved, owner: onHandle }; + onHandle?.(handle); + } + if (failed) throw outgoing; + return; + } + + // Same targets and live handle: update nonreplaying runtime data in place. + if (!sameOptions(options, previous.options)) { + updateHandle(previous.handle, options); + previous.options = options; + } + + // Declarative visibility only acts on a change from the last declarative + // value, so an unrelated render cannot undo an imperative show/hide. + if (resolved !== previous.visible) { + previous.visible = resolved; + if (resolved) void previous.handle.show(); + else previous.handle.hide(); + } + + // Transfer the callback without update, remount, or replay. `finally` + // notifies the new owner even when the outgoing callback throws. + if (onHandle !== previous.owner) { + const owner = previous.owner; + previous.owner = onHandle; + try { + owner?.(null); + } finally { + onHandle?.(previous.handle); + } + } }); - useEffect(() => () => { - mounted.current?.handle?.destroy(); - mounted.current = null; - }, []); -} -function optionsOf(props: StetOptions): StetOptions { - const { seed, roughness, boil, stroke, fill, width, resketchOnHover, padding, description } = props; - return { seed, roughness, boil, stroke, fill, width, resketchOnHover, padding, description }; + useEffect( + () => () => { + const current = mounted.current; + mounted.current = null; + if (!current?.handle) return; + const owner = current.owner; + current.handle.destroy(); + owner?.(null); + }, + [], + ); } -const dependenciesOf = (options: StetOptions): unknown[] => Object.values(options); - export function Circle(props: TargetProps): null { - const options = optionsOf(props); - useTarget(props.target, (element) => circle(element, options), dependenciesOf(options)); + useAnnotation( + [props.target], + sharedOptions(props), + props.visible, + props.onHandle, + (elements, options) => circle(elements[0], options as StetOptions), + ); + return null; +} + +export function Box(props: TargetProps): null { + useAnnotation( + [props.target], + sharedOptions(props), + props.visible, + props.onHandle, + (elements, options) => box(elements[0], options as StetOptions), + ); return null; } export function Underline(props: TargetProps): null { - const options = optionsOf(props); - useTarget(props.target, (element) => underline(element, options), dependenciesOf(options)); + useAnnotation( + [props.target], + sharedOptions(props), + props.visible, + props.onHandle, + (elements, options) => underline(elements[0], options as StetOptions), + ); return null; } export function Highlight(props: TargetProps): null { - const options = optionsOf(props); - useTarget(props.target, (element) => highlight(element, options), dependenciesOf(options)); + useAnnotation( + [props.target], + sharedOptions(props), + props.visible, + props.onHandle, + (elements, options) => highlight(elements[0], options as StetOptions), + ); return null; } export function Sticky(props: TargetProps): null { - const { target, text, side, offsetX, offsetY, ...shared } = props; - const options = { ...optionsOf(shared), text, side, offsetX, offsetY }; - useTarget(target, (element) => sticky(element, options), [ - ...dependenciesOf(optionsOf(shared)), - text, - side, offsetX, offsetY, - ]); + useAnnotation( + [props.target], + stickyOptions(props), + props.visible, + props.onHandle, + (elements, options) => sticky(elements[0], options as unknown as StickyOptions), + ); return null; } export function Mark(props: TargetProps & { kind: MarkKind }): null { - const { target, kind } = props; - const options = optionsOf(props); - useTarget(target, (element) => mark(element, kind, options), [...dependenciesOf(options), kind]); + useAnnotation( + [props.target], + markOptions(props), + props.visible, + props.onHandle, + (elements, options) => mark(elements[0], props.kind, options as StetOptions), + ); return null; } -export function Arrow({ - from, - to, - label, - curvature, - labelOffsetX, - labelOffsetY, - ...shared -}: ArrowOptions & { - from: RefObject; - to: RefObject; -}): null { - const options = { ...optionsOf(shared), label, curvature, labelOffsetX, labelOffsetY }; - useTargets([from, to], ([start, end]) => arrow(start, end, options), [ - ...dependenciesOf(optionsOf(shared)), label, curvature, labelOffsetX, labelOffsetY, - ]); +export function Arrow(props: ArrowProps): null { + useAnnotation( + [props.from, props.to], + arrowOptions(props), + props.visible, + props.onHandle, + (elements, options) => arrow(elements[0], elements[1], options as unknown as ArrowOptions), + ); return null; } diff --git a/src/rough.ts b/src/rough.ts index 9a03417..5f26627 100644 --- a/src/rough.ts +++ b/src/rough.ts @@ -27,6 +27,31 @@ export function roughLine(x1: number, y1: number, x2: number, y2: number, option return `M${point(at(0, random() * 0.6))}C${point(at(0.3, bow * (0.5 + random())))} ${point(at(0.72, bow * (0.5 + random())))} ${point(at(1, random() * 0.8))}`; } +// BOX-01: one bowed cubic per side. The caller keeps the shared pen so the +// corners stay put while each side wanders, as a hand-drawn box would. +function boxSide(random: () => number, x1: number, y1: number, x2: number, y2: number): string { + const dx = x2 - x1, dy = y2 - y1; + const length = Math.hypot(dx, dy) || 1; + const bow = Math.min(7, length * 0.035); + const at = (t: number, offset: number): Pt => [x1 + dx * t - dy / length * offset, y1 + dy * t + dx / length * offset]; + return `C${point(at(0.3, bow * (0.5 + random())))} ${point(at(0.72, bow * (0.5 + random())))} ${point(at(1, random() * 0.8))}`; +} + +// A box is one open perimeter gesture so path-length reveal can draw it end to +// end. The last side retraces the top edge, leaving the finishing overlap. +export function roughBox(x: number, y: number, w: number, h: number, options: RoughOptions): string { + const random = pen(options); + const width = Math.max(0, w), height = Math.max(0, h); + const overlap = Math.min(width * 0.2, 8); + const right = x + width, bottom = y + height; + return `M${point([x, y + random() * 0.6])}` + + boxSide(random, x, y, right, y) + + boxSide(random, right, y, right, bottom) + + boxSide(random, right, bottom, x, bottom) + + boxSide(random, x, bottom, x, y) + + boxSide(random, x, y, x + overlap, y); +} + // Catmull–Rom interpolation preserves the gesture through the sampled points. function curve(points: Pt[]): string { let d = `M${point(points[0])}`; diff --git a/src/svelte.ts b/src/svelte.ts index b2f8ec7..d2ab607 100644 --- a/src/svelte.ts +++ b/src/svelte.ts @@ -1,5 +1,7 @@ +import type { Action } from "svelte/action"; import { arrow as attachArrow, + box as attachBox, circle as attachCircle, highlight as attachHighlight, mark as attachMark, @@ -11,54 +13,230 @@ import { type StetOptions, type StickyOptions, } from "./index.js"; +import { updateHandle, type StetRuntimeOptions } from "./mount.js"; -export interface ActionReturn { - update(options: T): void; - destroy(): void; +type OnHandle = (handle: StetHandle | null) => void; + +/** Adapter-only metadata the action strips before any runtime use. */ +type AdapterMeta = { visible?: boolean; onHandle?: OnHandle }; + +/** Last reconciled state for one action instance. */ +interface Mounted { + elements: (Element | null)[]; + handle: StetHandle | null; + /** Complete non-visible runtime snapshot with adapter metadata stripped. */ + options: Record; + visible: boolean; + /** The callback that received the live handle; it alone receives the null. */ + owner: OnHandle | undefined; } -function action(attach: (element: Element, options: T) => StetHandle) { - return (element: Element, initial: T): ActionReturn => { - let handle = attach(element, initial); - let previous = snapshot(initial); - return { - update(options) { - if (sameOptions(options, previous)) return; - handle.destroy(); - handle = attach(element, options); - previous = snapshot(options); - }, - destroy: () => handle.destroy(), - }; - }; +interface Adapter { + /** Complete runtime snapshot excluding visibility and adapter metadata. */ + snapshot(value: T): Record; + /** Action host plus any required destination; null entries are unresolved. */ + targets(element: Element, value: T): (Element | null)[]; + attach(targets: Element[], options: StetRuntimeOptions): StetHandle; } -function snapshot(value: T): T { - return value && typeof value === "object" ? { ...value } : value; +/** Every runtime key, present even when undefined, so dropped values reset. */ +const sharedKeys: readonly (keyof StetOptions)[] = [ + "seed", + "roughness", + "boil", + "stroke", + "fill", + "width", + "resketchOnHover", + "padding", + "description", + "animate", + "animationDuration", + "animationDelay", +]; + +function sharedOptions(value: StetOptions): Record { + const options: Record = {}; + for (const key of sharedKeys) options[key] = value[key]; + return options; } -function sameOptions(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (!a || !b || typeof a !== "object" || typeof b !== "object") return false; - const keys = Object.keys(a); - return ( - keys.length === Object.keys(b).length && - keys.every((key) => Reflect.get(a, key) === Reflect.get(b, key)) - ); +function sameOptions(a: Record, b: Record): boolean { + return Object.keys(a).every((key) => a[key] === b[key]); } -function optionalAction(attach: (element: Element, options: StetOptions) => StetHandle) { - const run = action(attach); - return (element: Element, options: StetOptions = {}) => run(element, options); +/** + * Shared lifecycle for every Svelte primitive: target replacement, then + * nonreplaying updates, then changed-only visibility, then callback transfer. + * Svelte calls the action once per host, so the closure holds per-instance state. + */ +function action(adapter: Adapter): Action { + // Svelte's Action parameter is a conditional tuple that TypeScript cannot + // match against a concrete implementation, so implement plainly and cast once. + return ((element: HTMLElement, initial?: T) => { + let current: Mounted | null = null; + + const reconcile = (value: T | undefined) => { + const parameter = (value ?? {}) as T; + const elements = adapter.targets(element, parameter); + const options = adapter.snapshot(parameter); + const visible = parameter.visible ?? true; + const onHandle = parameter.onHandle; + const missing = elements.some((target) => target === null); + const previous = current; + + // No live handle: a missing target is a silent no-op; otherwise attach and + // deliver the handle to the current callback without a preceding null. + if (!previous || !previous.handle) { + if (missing) return; + const handle = adapter.attach(elements as Element[], { ...options, visible }); + // Commit adapter state before invoking user callbacks. + current = { elements, handle, options, visible, owner: onHandle }; + onHandle?.(handle); + return; + } + + const replaced = + elements.length !== previous.elements.length || + elements.some((target, index) => target !== previous.elements[index]); + + // Destroy first, send null to the owning callback, then reattach only if + // all targets exist. A throwing outgoing callback is surfaced after the + // replacement commits, so it cannot abort a valid reattach. + if (missing || replaced) { + const owner = previous.owner; + previous.handle.destroy(); + current = { elements, handle: null, options, visible, owner: undefined }; + let outgoing: unknown; + let failed = false; + try { + owner?.(null); + } catch (error) { + failed = true; + outgoing = error; + } + if (!missing) { + const handle = adapter.attach(elements as Element[], { ...options, visible }); + current = { elements, handle, options, visible, owner: onHandle }; + onHandle?.(handle); + } + if (failed) throw outgoing; + return; + } + + // Same targets and live handle: update nonreplaying runtime data in place. + if (!sameOptions(options, previous.options)) { + updateHandle(previous.handle, options); + previous.options = options; + } + + // Declarative visibility only acts on a change from the last declarative + // value, so an unrelated update cannot undo an imperative show/hide. + if (visible !== previous.visible) { + previous.visible = visible; + if (visible) void previous.handle.show(); + else previous.handle.hide(); + } + + // Transfer without update, remount, or replay; `finally` still notifies + // the new owner when the outgoing callback throws. + if (onHandle !== previous.owner) { + const owner = previous.owner; + previous.owner = onHandle; + try { + owner?.(null); + } finally { + onHandle?.(previous.handle); + } + } + }; + + reconcile(initial); + + return { + update(value: T | undefined) { + reconcile(value); + }, + destroy() { + const previous = current; + current = null; + if (!previous?.handle) return; + const owner = previous.owner; + previous.handle.destroy(); + owner?.(null); + }, + }; + }) as Action; } -export const circle = optionalAction(attachCircle); -export const underline = optionalAction(attachUnderline); -export const highlight = optionalAction(attachHighlight); -export const sticky = action(attachSticky); -export const mark = action((element, { kind, ...options }) => - attachMark(element, kind, options), -); -export const arrow = action((element, { to, ...options }) => - attachArrow(element, to, options), -); +export const circle: Action = action< + StetOptions & AdapterMeta +>({ + snapshot: sharedOptions, + targets: (element) => [element], + attach: ([element], options) => attachCircle(element, options as StetOptions), +}); + +export const box: Action = action< + StetOptions & AdapterMeta +>({ + snapshot: sharedOptions, + targets: (element) => [element], + attach: ([element], options) => attachBox(element, options as StetOptions), +}); + +export const underline: Action = action< + StetOptions & AdapterMeta +>({ + snapshot: sharedOptions, + targets: (element) => [element], + attach: ([element], options) => attachUnderline(element, options as StetOptions), +}); + +export const highlight: Action = action< + StetOptions & AdapterMeta +>({ + snapshot: sharedOptions, + targets: (element) => [element], + attach: ([element], options) => attachHighlight(element, options as StetOptions), +}); + +export const sticky: Action = action< + StickyOptions & AdapterMeta +>({ + snapshot: (value) => ({ + ...sharedOptions(value), + text: value.text, + side: value.side, + offsetX: value.offsetX, + offsetY: value.offsetY, + }), + targets: (element) => [element], + attach: ([element], options) => attachSticky(element, options as StickyOptions), +}); + +export const mark: Action = action< + StetOptions & { kind: MarkKind } & AdapterMeta +>({ + snapshot: (value) => ({ ...sharedOptions(value), kind: value.kind }), + targets: (element) => [element], + attach: ([element], options) => { + const { kind, ...rest } = options as StetOptions & { kind: MarkKind }; + return attachMark(element, kind, rest); + }, +}); + +export const arrow: Action = + action({ + snapshot: (value) => ({ + ...sharedOptions(value), + label: value.label, + curvature: value.curvature, + labelOffsetX: value.labelOffsetX, + labelOffsetY: value.labelOffsetY, + }), + // An absent destination is as unresolved as a null one: never pass either to + // the primitive, and wait silently until an Element appears. + targets: (element, value) => [element, value.to ?? null], + attach: ([element, to], options) => attachArrow(element, to, options as ArrowOptions), + }); diff --git a/src/vue.ts b/src/vue.ts index 6200217..a286dbc 100644 --- a/src/vue.ts +++ b/src/vue.ts @@ -1,6 +1,7 @@ -import type { Directive } from "vue"; +import type { Directive, DirectiveBinding } from "vue"; import { arrow as attachArrow, + box as attachBox, circle as attachCircle, highlight as attachHighlight, mark as attachMark, @@ -12,57 +13,220 @@ import { type StetOptions, type StickyOptions, } from "./index.js"; +import { updateHandle, type StetRuntimeOptions } from "./mount.js"; -function directive( - attach: (element: Element, value: T) => StetHandle, -): Directive { - const handles = new WeakMap(); - const previous = new WeakMap(); - const mount = (element: Element, value: T) => { - handles.set(element, attach(element, value)); - previous.set(element, value && typeof value === "object" ? { ...value } : value); +type OnHandle = (handle: StetHandle | null) => void; + +/** Adapter-only metadata the directive strips before any runtime use. */ +type AdapterMeta = { visible?: boolean; onHandle?: OnHandle }; + +/** Last reconciled state for one directive host. */ +interface Mounted { + elements: (Element | null)[]; + handle: StetHandle | null; + /** Complete non-visible runtime snapshot with adapter metadata stripped. */ + options: Record; + visible: boolean; + /** The callback that received the live handle; it alone receives the null. */ + owner: OnHandle | undefined; +} + +interface Adapter { + /** Complete runtime snapshot excluding visibility and adapter metadata. */ + snapshot(value: T): Record; + /** Required targets; null entries are unresolved. */ + targets(element: Element, value: T): (Element | null)[]; + attach(targets: Element[], options: StetRuntimeOptions): StetHandle; +} + +/** + * Complete non-visible runtime snapshot. Every known field is present so a + * later update that drops a value resets it to its default through + * updateHandle. `visible`, `onHandle`, and adapter targets never enter it. + */ +const sharedKeys: readonly (keyof StetOptions)[] = [ + "seed", + "roughness", + "boil", + "stroke", + "fill", + "width", + "resketchOnHover", + "padding", + "description", + "animate", + "animationDuration", + "animationDelay", +]; + +function sharedOptions(value: StetOptions): Record { + const options: Record = {}; + for (const key of sharedKeys) options[key] = value[key]; + return options; +} + +function sameOptions(a: Record, b: Record): boolean { + return Object.keys(a).every((key) => a[key] === b[key]); +} + +/** + * Shared directive lifecycle for every Vue primitive. Applies the adapter + * contract's ordered algorithm on each hook: target replacement first, then + * nonreplaying updates, then changed-only visibility, then callback transfer. + * + * Directive state is keyed by the host element, so Vue's own host replacement + * arrives as unmounted-then-mounted and follows the destroy/null/attach rules + * without a special case here. + */ +function directive(adapter: Adapter): Directive { + const mounted = new WeakMap(); + + const reconcile = (element: Element, binding: DirectiveBinding) => { + const value = (binding.value ?? {}) as T; + const elements = adapter.targets(element, value); + const options = adapter.snapshot(value); + const visible = value.visible ?? true; + const onHandle = value.onHandle; + const missing = elements.some((target) => target === null); + const previous = mounted.get(element); + + // No live handle: a missing target is a silent no-op; otherwise attach and + // deliver the handle to the current callback without a preceding null. + if (!previous || !previous.handle) { + if (missing) return; + const handle = adapter.attach(elements as Element[], { ...options, visible }); + // Commit adapter state before invoking user callbacks. + mounted.set(element, { elements, handle, options, visible, owner: onHandle }); + onHandle?.(handle); + return; + } + + const replaced = + elements.length !== previous.elements.length || + elements.some((target, index) => target !== previous.elements[index]); + + // Target replacement or a missing target destroys first, sends null to the + // callback that owned the handle, then reattaches only if all targets exist. + // A throwing outgoing callback is surfaced but must not abort a valid + // replacement, so its failure is captured until the new handle is committed. + if (missing || replaced) { + const owner = previous.owner; + previous.handle.destroy(); + mounted.set(element, { elements, handle: null, options, visible, owner: undefined }); + let outgoing: unknown; + let failed = false; + try { + owner?.(null); + } catch (error) { + failed = true; + outgoing = error; + } + if (!missing) { + const handle = adapter.attach(elements as Element[], { ...options, visible }); + mounted.set(element, { elements, handle, options, visible, owner: onHandle }); + onHandle?.(handle); + } + if (failed) throw outgoing; + return; + } + + // Same targets and live handle: update nonreplaying runtime data in place. + if (!sameOptions(options, previous.options)) { + updateHandle(previous.handle, options); + previous.options = options; + } + + // Declarative visibility only acts on a change from the last declarative + // value, so an unrelated update cannot undo an imperative show/hide. + if (visible !== previous.visible) { + previous.visible = visible; + if (visible) void previous.handle.show(); + else previous.handle.hide(); + } + + // Transfer the callback without update, remount, or replay. `finally` + // notifies the new owner even when the outgoing callback throws. + if (onHandle !== previous.owner) { + const owner = previous.owner; + previous.owner = onHandle; + try { + owner?.(null); + } finally { + onHandle?.(previous.handle); + } + } }; + return { deep: true, - mounted: (element, binding) => mount(element, binding.value), - updated(element, binding) { - if (sameOptions(binding.value, previous.get(element))) return; - handles.get(element)?.destroy(); - mount(element, binding.value); - }, + mounted: (element, binding) => reconcile(element, binding), + updated: (element, binding) => reconcile(element, binding), unmounted(element) { - handles.get(element)?.destroy(); - handles.delete(element); - previous.delete(element); + const current = mounted.get(element); + mounted.delete(element); + if (!current?.handle) return; + const owner = current.owner; + current.handle.destroy(); + owner?.(null); }, }; } -function sameOptions(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (!a || !b || typeof a !== "object" || typeof b !== "object") return false; - const keys = Object.keys(a); - return ( - keys.length === Object.keys(b).length && - keys.every((key) => Reflect.get(a, key) === Reflect.get(b, key)) - ); -} +export const vStetCircle = directive({ + snapshot: sharedOptions, + targets: (element) => [element], + attach: ([element], options) => attachCircle(element, options as StetOptions), +}); + +export const vStetBox = directive({ + snapshot: sharedOptions, + targets: (element) => [element], + attach: ([element], options) => attachBox(element, options as StetOptions), +}); + +export const vStetUnderline = directive({ + snapshot: sharedOptions, + targets: (element) => [element], + attach: ([element], options) => attachUnderline(element, options as StetOptions), +}); + +export const vStetHighlight = directive({ + snapshot: sharedOptions, + targets: (element) => [element], + attach: ([element], options) => attachHighlight(element, options as StetOptions), +}); + +export const vStetSticky = directive({ + snapshot: (value) => ({ + ...sharedOptions(value), + text: value.text, + side: value.side, + offsetX: value.offsetX, + offsetY: value.offsetY, + }), + targets: (element) => [element], + attach: ([element], options) => attachSticky(element, options as StickyOptions), +}); + +export const vStetMark = directive({ + snapshot: (value) => ({ ...sharedOptions(value), kind: value.kind }), + targets: (element) => [element], + attach: ([element], options) => { + const { kind, ...rest } = options as StetOptions & { kind: MarkKind }; + return attachMark(element, kind, rest); + }, +}); -export const vStetCircle = directive((element, options = {}) => - attachCircle(element, options), -); -export const vStetUnderline = directive((element, options = {}) => - attachUnderline(element, options), -); -export const vStetHighlight = directive((element, options = {}) => - attachHighlight(element, options), -); -export const vStetSticky = directive((element, options) => - attachSticky(element, options), -); -export const vStetMark = directive( - (element, { kind, ...options }) => attachMark(element, kind, options), -); -export const vStetArrow = directive((element, { to, ...options }) => - attachArrow(element, to, options), -); +export const vStetArrow = directive({ + snapshot: (value) => ({ + ...sharedOptions(value), + label: value.label, + curvature: value.curvature, + labelOffsetX: value.labelOffsetX, + labelOffsetY: value.labelOffsetY, + }), + // An absent destination is as unresolved as a null one: never pass either to + // the primitive, and wait silently until an Element appears. + targets: (element, value) => [element, value.to ?? null], + attach: ([element, to], options) => attachArrow(element, to, options as ArrowOptions), +}); diff --git a/tests/agent/cli.test.ts b/tests/agent/cli.test.ts index 892248e..8d2fcbe 100644 --- a/tests/agent/cli.test.ts +++ b/tests/agent/cli.test.ts @@ -1,75 +1,165 @@ // @vitest-environment node -import { afterEach, describe, expect, it } from 'vitest'; -import { spawnSync } from 'node:child_process'; -import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync, symlinkSync, existsSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { resolve, join } from 'node:path'; -import { createHash } from 'node:crypto'; +import { afterEach, describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + writeFileSync, + readFileSync, + mkdirSync, + rmSync, + symlinkSync, + existsSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve, join } from "node:path"; +import { createHash } from "node:crypto"; -const cli = resolve('agent/cli.mjs'); +const cli = resolve("agent/cli.mjs"); const dirs: string[] = []; -const temporary = () => { const p = mkdtempSync(join(tmpdir(), 'stet-agent-')); dirs.push(p); return p; }; +const temporary = () => { + const p = mkdtempSync(join(tmpdir(), "stet-agent-")); + dirs.push(p); + return p; +}; const run = (args: string[], cwd = process.cwd()) => { - const result = spawnSync(process.execPath, [cli, ...args], { cwd, encoding: 'utf8' }); + const result = spawnSync(process.execPath, [cli, ...args], { cwd, encoding: "utf8" }); if (result.error) throw result.error; return result; }; -afterEach(() => dirs.splice(0).forEach(p => rmSync(p, { recursive: true, force: true }))); -const plan = { version: 1, framework: 'react', intent: 'explain', annotations: [{ id: 'save', primitive: 'circle', targets: [{ strategy: 'ref', file: 'src/App.tsx', locator: 'saveRef', description: 'Save button' }], options: { seed: 42, description: 'Save your changes.' } }] }; +afterEach(() => dirs.splice(0).forEach((p) => rmSync(p, { recursive: true, force: true }))); +const plan = { + version: 1, + framework: "react", + intent: "explain", + annotations: [ + { + id: "save", + primitive: "circle", + targets: [ + { strategy: "ref", file: "src/App.tsx", locator: "saveRef", description: "Save button" }, + ], + options: { seed: 42, description: "Save your changes." }, + }, + ], +}; -describe('CLI subprocess contract', () => { - it('prints help/version and repeatable JSON with empty stderr', () => { - for (const args of [['--help'], ['--version'], ['inspect'], ['schema', 'annotation-plan'], ['schema', 'capabilities'], ['snippet', 'circle', '--framework', 'react']]) { - const a = run([...args, '--json']), b = run([...args, '--json']); - expect(a.status).toBe(0); expect(a.stderr).toBe(''); expect(a.stdout).toBe(b.stdout); expect(JSON.parse(a.stdout)).toBeTruthy(); +describe("CLI subprocess contract", () => { + it("prints help/version and repeatable JSON with empty stderr", () => { + for (const args of [ + ["--help"], + ["--version"], + ["inspect"], + ["schema", "annotation-plan"], + ["schema", "capabilities"], + ["snippet", "circle", "--framework", "react"], + ]) { + const a = run([...args, "--json"]), + b = run([...args, "--json"]); + expect(a.status).toBe(0); + expect(a.stderr).toBe(""); + expect(a.stdout).toBe(b.stdout); + expect(JSON.parse(a.stdout)).toBeTruthy(); } - expect(run(['--help']).stdout).toContain('Exit codes:'); + expect(run(["--help"]).stdout).toContain("Exit codes:"); }); - it('rejects unknown commands, extra flags and invalid snippet names', () => { - for (const args of [['inspect', '--project', ''], ['inspect', '--pattern', ' '], ['--version', '--tool', ''], ['wat'], ['--framework', 'react'], ['inspect', '', ''], ['inspect', '--tool', 'claude'], ['schema', '../../package'], ['snippet', 'box', '--framework', 'react'], ['snippet', 'circle', '--framework', 'solid'], ['inspect', 'extra'], ['validate'], ['inspect', '--bogus'], ['agent', 'init', '--tool', 'toString']]) { - const result = run([...args, '--json']); expect(result.status, args.join(' ')).toBe(2); expect(JSON.parse(result.stdout).ok).toBe(false); expect(result.stderr).toBe(''); + it("rejects unknown commands, extra flags and invalid snippet names", () => { + for (const args of [ + ["inspect", "--project", ""], + ["inspect", "--pattern", " "], + ["--version", "--tool", ""], + ["wat"], + ["--framework", "react"], + ["inspect", "", ""], + ["inspect", "--tool", "claude"], + ["schema", "../../package"], + ["snippet", "bracket", "--framework", "react"], + ["snippet", "circle", "--framework", "solid"], + ["inspect", "extra"], + ["validate"], + ["inspect", "--bogus"], + ["agent", "init", "--tool", "toString"], + ]) { + const result = run([...args, "--json"]); + expect(result.status, args.join(" ")).toBe(2); + expect(JSON.parse(result.stdout).ok).toBe(false); + expect(result.stderr).toBe(""); } - expect(run(['wat']).stderr).toContain('USAGE'); expect(run(['wat']).stdout).toBe(''); + expect(run(["wat"]).stderr).toContain("USAGE"); + expect(run(["wat"]).stdout).toBe(""); }); - it('validates files and distinguishes invalid plans, syntax and I/O', () => { - const cwd = temporary(); writeFileSync(join(cwd, 'plan.json'), JSON.stringify(plan)); - expect(JSON.parse(run(['validate', 'plan.json', '--json'], cwd).stdout)).toEqual({ ok: true, errors: [], warnings: [] }); - writeFileSync(join(cwd, 'plan.json'), JSON.stringify({ ...plan, version: 2 })); - const invalid = run(['validate', 'plan.json', '--json'], cwd); expect(invalid.status).toBe(1); expect(JSON.parse(invalid.stdout).errors[0].path).toBe('version'); - expect(run(['validate', 'plan.json'], cwd).stderr).toContain('version'); - writeFileSync(join(cwd, 'plan.json'), '{'); expect(run(['validate', 'plan.json', '--json'], cwd).status).toBe(3); - expect(run(['validate', 'absent.json', '--json'], cwd).status).toBe(3); + it("validates files and distinguishes invalid plans, syntax and I/O", () => { + const cwd = temporary(); + writeFileSync(join(cwd, "plan.json"), JSON.stringify(plan)); + expect(JSON.parse(run(["validate", "plan.json", "--json"], cwd).stdout)).toEqual({ + ok: true, + errors: [], + warnings: [], + }); + writeFileSync(join(cwd, "plan.json"), JSON.stringify({ ...plan, version: 2 })); + const invalid = run(["validate", "plan.json", "--json"], cwd); + expect(invalid.status).toBe(1); + expect(JSON.parse(invalid.stdout).errors[0].path).toBe("version"); + expect(run(["validate", "plan.json"], cwd).stderr).toContain("version"); + writeFileSync(join(cwd, "plan.json"), "{"); + expect(run(["validate", "plan.json", "--json"], cwd).status).toBe(3); + expect(run(["validate", "absent.json", "--json"], cwd).status).toBe(3); }); }); -for (const [tool, path] of Object.entries({ claude: '.claude/skills', cursor: '.agents/skills', opencode: '.agents/skills', codex: '.agents/skills' })) { +for (const [tool, path] of Object.entries({ + claude: ".claude/skills", + cursor: ".agents/skills", + opencode: ".agents/skills", + codex: ".agents/skills", +})) { it(`installs and updates ${tool} idempotently without touching unrelated config`, () => { - const cwd = temporary(); writeFileSync(join(cwd, 'AGENTS.md'), 'personal instructions'); - const args = ['agent', 'init', '--tool', tool, '--json']; - expect(run(args, cwd).status).toBe(0); expect(existsSync(join(cwd, path, 'stet/SKILL.md'))).toBe(true); + const cwd = temporary(); + writeFileSync(join(cwd, "AGENTS.md"), "personal instructions"); + const args = ["agent", "init", "--tool", tool, "--json"]; + expect(run(args, cwd).status).toBe(0); + expect(existsSync(join(cwd, path, "stet/SKILL.md"))).toBe(true); expect(JSON.parse(run(args, cwd).stdout).changed).toEqual([]); - expect(JSON.parse(run(['agent', 'update', '--tool', tool, '--json'], cwd).stdout).changed).toEqual([]); - expect(readFileSync(join(cwd, 'AGENTS.md'), 'utf8')).toBe('personal instructions'); - const file = join(cwd, path, 'stet/SKILL.md'); writeFileSync(file, 'my local skill'); - const result = run(['agent', 'update', '--tool', tool, '--json'], cwd); - expect(result.status).toBe(4); expect(JSON.parse(result.stdout).errors[0].code).toBe('LOCAL_CHANGES'); expect(readFileSync(file, 'utf8')).toBe('my local skill'); + expect( + JSON.parse(run(["agent", "update", "--tool", tool, "--json"], cwd).stdout).changed, + ).toEqual([]); + expect(readFileSync(join(cwd, "AGENTS.md"), "utf8")).toBe("personal instructions"); + const file = join(cwd, path, "stet/SKILL.md"); + writeFileSync(file, "my local skill"); + const result = run(["agent", "update", "--tool", tool, "--json"], cwd); + expect(result.status).toBe(4); + expect(JSON.parse(result.stdout).errors[0].code).toBe("LOCAL_CHANGES"); + expect(readFileSync(file, "utf8")).toBe("my local skill"); }); } -it('preflights conflicts before copying any skills; protects symlinks', () => { - const cwd = temporary(); mkdirSync(join(cwd, '.agents/skills/stet-showcase-ui'), { recursive: true }); - writeFileSync(join(cwd, '.agents/skills/stet-showcase-ui/SKILL.md'), 'custom'); - expect(run(['agent', 'init', '--tool', 'codex', '--json'], cwd).status).toBe(4); - expect(existsSync(join(cwd, '.agents/skills/stet'))).toBe(false); - const other = temporary(); const outside = temporary(); symlinkSync(outside, join(other, '.agents')); - expect(run(['agent', 'init', '--tool', 'codex', '--json'], other).status).toBe(4); - expect(existsSync(join(outside, 'skills'))).toBe(false); +it("preflights conflicts before copying any skills; protects symlinks", () => { + const cwd = temporary(); + mkdirSync(join(cwd, ".agents/skills/stet-showcase-ui"), { recursive: true }); + writeFileSync(join(cwd, ".agents/skills/stet-showcase-ui/SKILL.md"), "custom"); + expect(run(["agent", "init", "--tool", "codex", "--json"], cwd).status).toBe(4); + expect(existsSync(join(cwd, ".agents/skills/stet"))).toBe(false); + const other = temporary(); + const outside = temporary(); + symlinkSync(outside, join(other, ".agents")); + expect(run(["agent", "init", "--tool", "codex", "--json"], other).status).toBe(4); + expect(existsSync(join(outside, "skills"))).toBe(false); }); -it('updates a previously managed version and rejects deleted/invalid managed files', () => { - const cwd = temporary(); const args = ['agent', 'update', '--tool', 'codex', '--json']; - expect(run(args, cwd).status).toBe(4); run(['agent', 'init', '--tool', 'codex', '--json'], cwd); - const file = join(cwd, '.agents/skills/stet/SKILL.md'); const marker = join(cwd, '.agents/skills/.stet-managed.json'); - const original = readFileSync(file, 'utf8'); writeFileSync(file, 'older official version'); - const manifest = JSON.parse(readFileSync(marker, 'utf8')); manifest.files['stet/SKILL.md'] = createHash('sha256').update('older official version').digest('hex'); writeFileSync(marker, JSON.stringify(manifest)); - expect(run(args, cwd).status).toBe(0); expect(readFileSync(file, 'utf8')).toBe(original); - rmSync(file); expect(run(args, cwd).status).toBe(4); - writeFileSync(marker, '{}'); expect(JSON.parse(run(args, cwd).stdout).errors[0].code).toBe('INVALID_MANIFEST'); +it("updates a previously managed version and rejects deleted/invalid managed files", () => { + const cwd = temporary(); + const args = ["agent", "update", "--tool", "codex", "--json"]; + expect(run(args, cwd).status).toBe(4); + run(["agent", "init", "--tool", "codex", "--json"], cwd); + const file = join(cwd, ".agents/skills/stet/SKILL.md"); + const marker = join(cwd, ".agents/skills/.stet-managed.json"); + const original = readFileSync(file, "utf8"); + writeFileSync(file, "older official version"); + const manifest = JSON.parse(readFileSync(marker, "utf8")); + manifest.files["stet/SKILL.md"] = createHash("sha256") + .update("older official version") + .digest("hex"); + writeFileSync(marker, JSON.stringify(manifest)); + expect(run(args, cwd).status).toBe(0); + expect(readFileSync(file, "utf8")).toBe(original); + rmSync(file); + expect(run(args, cwd).status).toBe(4); + writeFileSync(marker, "{}"); + expect(JSON.parse(run(args, cwd).stdout).errors[0].code).toBe("INVALID_MANIFEST"); }); diff --git a/tests/agent/patterns.test.ts b/tests/agent/patterns.test.ts index e880481..6e466bf 100644 --- a/tests/agent/patterns.test.ts +++ b/tests/agent/patterns.test.ts @@ -1,18 +1,53 @@ // @vitest-environment node -import { expect, it } from 'vitest'; -import { transform } from 'esbuild'; +import { expect, it } from "vitest"; +import { transform } from "esbuild"; // @ts-expect-error Node-only template factory -import { lifecyclePattern } from '../../agent/patterns.mjs'; -it('rolls back partially attached groups and makes teardown idempotent', async () => { - const code = lifecyclePattern('vanilla').code.replace(/^import .*;$/gm, ''); - const compiled = await transform(code, { loader: 'ts', format: 'esm' }); +import { lifecyclePattern } from "../../agent/patterns.mjs"; +// @ts-expect-error Node-only template factory +import { snippet } from "../../agent/snippets.mjs"; +it("rolls back partially attached groups and makes teardown idempotent", async () => { + const code = lifecyclePattern("vanilla").code.replace(/^import .*;$/gm, ""); + const compiled = await transform(code, { loader: "ts", format: "esm" }); const events: string[] = []; - const attach = (name: string) => () => { events.push(name); return { destroy: () => events.push(`destroy ${name}`) }; }; - const make = new Function('circle', 'sticky', 'arrow', compiled.code.replace('export {\n annotate\n};', '') + '\nreturn annotate;'); - const annotate = make(attach('circle'), attach('sticky'), () => { throw Error('invalid destination'); }); + const attach = (name: string) => () => { + events.push(name); + return { destroy: () => events.push(`destroy ${name}`) }; + }; + const make = new Function( + "circle", + "sticky", + "arrow", + compiled.code.replace(/export\s*\{[\s\S]*?\};?/, "") + "\nreturn annotate;", + ); + const annotate = make(attach("circle"), attach("sticky"), () => { + throw Error("invalid destination"); + }); const marks = annotate({}); - expect(() => marks.update(true, {})).toThrow('invalid destination'); - expect(events).toEqual(['circle', 'sticky', 'destroy sticky', 'destroy circle']); - marks.destroy(); marks.destroy(); + expect(() => marks.update(true, {})).toThrow("invalid destination"); + expect(events).toEqual(["circle", "sticky", "destroy sticky", "destroy circle"]); + marks.destroy(); + marks.destroy(); expect(events.length).toBe(4); }); + +it("generates cancellation-aware motion sequencing for every framework", () => { + for (const framework of ["vanilla", "react", "vue", "svelte", "angular"]) { + const code = lifecyclePattern(framework).code; + for (const token of [ + "visible: false", + "animate: true", + ".show()", + ".hide()", + ".replay()", + '"cancelled"', + ]) + expect(code, `${framework}: ${token}`).toContain(token); + } +}); + +it("uses each adapter handle callback in generated snippets", () => { + expect(snippet("box", "react").code).toContain("onHandle={onHandle}"); + expect(snippet("box", "vue").code).toContain("onHandle }"); + expect(snippet("box", "svelte").code).toContain("onHandle }"); + expect(snippet("box", "angular").code).toContain('[stetOnHandle]="onHandle"'); +}); diff --git a/tests/agent/schema.test.ts b/tests/agent/schema.test.ts index e97b28f..a585f15 100644 --- a/tests/agent/schema.test.ts +++ b/tests/agent/schema.test.ts @@ -27,6 +27,31 @@ it("capabilities match their published schema and expose package exports", () => exports: Object.keys(pkg.exports), }); expect(pkg.dependencies).toBeUndefined(); + for (const method of [ + "show()", + "hide()", + "replay()", + "refresh()", + "resketch(seed?)", + "destroy()", + ]) + expect(caps.constraints.lifecycle).toContain(method); +}); + +it("keeps version-1 plans using the original six primitives valid", () => { + const target = { strategy: "id", file: "index.html", locator: "target", description: "Target" }; + const annotations = ["circle", "underline", "highlight", "arrow", "sticky", "mark"].map( + (primitive) => ({ + id: primitive, + primitive, + targets: Array.from({ length: primitive === "arrow" ? 2 : 1 }, () => target), + options: primitive === "sticky" ? { text: "Note" } : {}, + ...(primitive === "mark" ? { kind: "right" } : {}), + }), + ); + expect(validatePlan({ version: 1, framework: "vanilla", intent: "legacy", annotations }).ok).toBe( + true, + ); }); it("accepts every primitive/framework and rejects each incompatible option", () => { const caps = read("agent/capabilities.json"); @@ -77,7 +102,7 @@ it("matches runtime animation validation and reveal support", () => { ], }); - for (const primitive of ["circle", "underline"]) { + for (const primitive of ["box", "circle", "underline"]) { expect(validatePlan(plan(primitive, { animate: true })).ok).toBe(true); expect(validatePlan(plan(primitive, { animationDuration: 0, animationDelay: 0 })).ok).toBe( true, diff --git a/tests/angular.test.ts b/tests/angular.test.ts new file mode 100644 index 0000000..0e81912 --- /dev/null +++ b/tests/angular.test.ts @@ -0,0 +1,502 @@ +import "@angular/compiler"; +import { Component, provideZonelessChangeDetection, type Type } from "@angular/core"; +import { TestBed, type ComponentFixture } from "@angular/core/testing"; +import { BrowserTestingModule, platformBrowserTesting } from "@angular/platform-browser/testing"; +import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest"; +import { + StetArrowDirective, + StetCircleDirective, + StetMarkDirective, + StetStickyDirective, +} from "../src/angular.js"; +import type { + ArrowOptions, + MarkKind, + StetHandle, + StetOptions, + StickyOptions, +} from "../src/index.js"; + +type Callback = (handle: StetHandle | null) => void; + +/** + * happy-dom returns zero-sized rects, which the runtime treats as culled. Pin a + * fixed onscreen box so overlay `hidden` reflects explicit visibility, not + * geometry. + */ +const RECT = new DOMRect(10, 20, 100, 30); + +// Runtime JIT compilation of the consumer templates below needs the compiler. +beforeAll(() => { + TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting(), { + teardown: { destroyAfterEach: true }, + }); +}); + +beforeEach(() => { + vi.stubGlobal("innerWidth", 1024); + vi.stubGlobal("innerHeight", 768); + vi.stubGlobal("matchMedia", () => ({ matches: false })); + vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue(RECT); + vi.spyOn(Element.prototype, "getClientRects").mockReturnValue([RECT] as unknown as DOMRectList); + TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] }); +}); + +afterEach(() => { + TestBed.resetTestingModule(); + document.body.textContent = ""; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +@Component({ + selector: "ad-circle-host", + standalone: true, + imports: [StetCircleDirective], + template: ``, +}) +class CircleHost { + options: StetOptions = {}; + onHandle?: Callback; +} + +@Component({ + selector: "ad-arrow-host", + standalone: true, + imports: [StetArrowDirective], + template: ``, +}) +class ArrowHost { + options: ArrowOptions & { to?: Element | null } = {}; + onHandle?: Callback; +} + +@Component({ + selector: "ad-mark-host", + standalone: true, + imports: [StetMarkDirective], + template: ``, +}) +class MarkHost { + options: StetOptions & { kind: MarkKind } = { kind: "right" }; + onHandle?: Callback; +} + +@Component({ + selector: "ad-sticky-host", + standalone: true, + imports: [StetStickyDirective], + template: ``, +}) +class StickyHost { + options: StickyOptions = { text: "" }; + onHandle?: Callback; +} + +/** Consumer template whose host element is swapped or removed by control flow. */ +@Component({ + selector: "ad-swap-host", + standalone: true, + imports: [StetCircleDirective], + template: `@if (present) { + @if (tag === "button") { + + } @else { + Save + } + }`, +}) +class SwapHost { + present = true; + tag: "button" | "a" = "button"; + options: StetOptions = {}; + onHandle?: Callback; +} + +function collector(): { calls: (StetHandle | null)[]; onHandle: Callback } { + const calls: (StetHandle | null)[] = []; + return { calls, onHandle: (handle) => calls.push(handle) }; +} + +/** Create, configure before the deferred first render, then render once. */ +function create(type: Type, configure?: (instance: T) => void): ComponentFixture { + const fixture = TestBed.createComponent(type); + configure?.(fixture.componentInstance); + fixture.detectChanges(); + TestBed.tick(); + return fixture; +} + +function update(fixture: ComponentFixture, configure: (instance: T) => void): void { + configure(fixture.componentInstance); + fixture.changeDetectorRef.markForCheck(); + TestBed.tick(); +} + +function overlay(): HTMLElement { + return document.querySelector(".stet-overlay")!; +} + +function ink(): string { + return overlay().style.getPropertyValue("--stet-ink"); +} + +it("compiles the inherited stetOnHandle input, then reports null on destroy", () => { + const { calls, onHandle } = collector(); + const fixture = create(CircleHost, (host) => { + host.options = { seed: 1 }; + host.onHandle = onHandle; + }); + + expect(calls).toHaveLength(1); + const handle = calls[0]!; + expect(handle).not.toBeNull(); + expect(document.querySelectorAll(".stet-overlay")).toHaveLength(1); + + fixture.destroy(); + expect(calls).toEqual([handle, null]); + expect(document.querySelector(".stet-overlay")).toBeNull(); +}); + +it("defers attachment until after the first render", () => { + const fixture = TestBed.createComponent(CircleHost); + fixture.componentInstance.options = { seed: 1 }; + + // The constructor runs during creation; afterNextRender keeps the DOM untouched. + expect(document.querySelector(".stet-overlay")).toBeNull(); + + fixture.detectChanges(); + TestBed.tick(); + expect(document.querySelector(".stet-overlay")).not.toBeNull(); + fixture.destroy(); +}); + +it("applies a changed visible value once and preserves imperative state", () => { + const { calls, onHandle } = collector(); + const fixture = create(CircleHost, (host) => { + host.options = { seed: 1, visible: false }; + host.onHandle = onHandle; + }); + expect(overlay().hidden).toBe(true); + const handle = calls[0]!; + const show = vi.spyOn(handle, "show"); + const hide = vi.spyOn(handle, "hide"); + + update(fixture, (host) => { + host.options = { seed: 1, visible: false }; + }); + expect(show).not.toHaveBeenCalled(); + expect(hide).not.toHaveBeenCalled(); + + update(fixture, (host) => { + host.options = { seed: 1, visible: true }; + }); + expect(show).toHaveBeenCalledTimes(1); + expect(overlay().hidden).toBe(false); + + handle.hide(); + expect(hide).toHaveBeenCalledTimes(1); + expect(overlay().hidden).toBe(true); + + // An unrelated update must not overwrite the imperative hide. + update(fixture, (host) => { + host.options = { seed: 1, visible: true, stroke: "green" }; + }); + expect(hide).toHaveBeenCalledTimes(1); + expect(show).toHaveBeenCalledTimes(1); + expect(overlay().hidden).toBe(true); + + update(fixture, (host) => { + host.options = { seed: 1, visible: false, stroke: "green" }; + }); + expect(hide).toHaveBeenCalledTimes(2); + + // Explicit false -> omitted is a change to the default true. + update(fixture, (host) => { + host.options = { seed: 1, stroke: "green" }; + }); + expect(show).toHaveBeenCalledTimes(2); + expect(overlay().hidden).toBe(false); +}); + +it("updates style, description, and placement in place without remount or replay", () => { + const { calls, onHandle } = collector(); + const fixture = create(CircleHost, (host) => { + host.options = { seed: 1, stroke: "red" }; + host.onHandle = onHandle; + }); + + const handle = calls[0]!; + const element = overlay(); + const replay = vi.spyOn(handle, "replay"); + const narrow = parseFloat(overlay().style.width); + + update(fixture, (host) => { + host.options = { seed: 1, stroke: "blue", description: "Explained", padding: 20 }; + }); + + expect(calls).toEqual([handle]); + expect(overlay()).toBe(element); + expect(ink()).toBe("blue"); + expect(document.querySelector(".stet-description")?.textContent).toBe("Explained"); + expect(replay).not.toHaveBeenCalled(); + expect(parseFloat(overlay().style.width)).toBeGreaterThan(narrow); + expect(document.querySelector("button.host")!.getAttribute("aria-describedby")).toContain( + "stet-description-", + ); +}); + +it("sends a complete snapshot so removed values return to defaults", () => { + const { calls, onHandle } = collector(); + const fixture = create(CircleHost, (host) => { + host.options = { seed: 1, padding: 20, description: "A" }; + host.onHandle = onHandle; + }); + + const handle = calls[0]!; + const wide = parseFloat(overlay().style.width); + expect(document.querySelector(".stet-description")).not.toBeNull(); + + update(fixture, (host) => { + host.options = { seed: 1 }; + }); + + expect(parseFloat(overlay().style.width)).toBeLessThan(wide); + expect(document.querySelector(".stet-description")).toBeNull(); + expect(calls).toEqual([handle]); +}); + +it("updates primitive-specific options in place: mark kind and sticky text", () => { + const markCollector = collector(); + const markFixture = create(MarkHost, (host) => { + host.options = { kind: "right", seed: 1 }; + host.onHandle = markCollector.onHandle; + }); + const markHandle = markCollector.calls[0]!; + expect(document.querySelector(".stet-mark--right")).not.toBeNull(); + + update(markFixture, (host) => { + host.options = { kind: "wrong", seed: 1 }; + }); + expect(document.querySelector(".stet-mark--wrong")).not.toBeNull(); + expect(document.querySelector(".stet-mark--right")).toBeNull(); + expect(markCollector.calls).toEqual([markHandle]); + + const stickyCollector = collector(); + const stickyFixture = create(StickyHost, (host) => { + host.options = { text: "One", seed: 1 }; + host.onHandle = stickyCollector.onHandle; + }); + const stickyHandle = stickyCollector.calls[0]!; + expect(document.querySelector(".stet-sticky-text")?.textContent).toBe("One"); + + update(stickyFixture, (host) => { + host.options = { text: "Two", seed: 1 }; + }); + expect(document.querySelector(".stet-sticky-text")?.textContent).toBe("Two"); + expect(stickyCollector.calls).toEqual([stickyHandle]); +}); + +it("transfers a live handle to a new callback without remount, update, or replay", () => { + const a = collector(); + const b = collector(); + const fixture = create(CircleHost, (host) => { + host.options = { seed: 1 }; + host.onHandle = a.onHandle; + }); + + const handle = a.calls[0]!; + const element = overlay(); + const replay = vi.spyOn(handle, "replay"); + const show = vi.spyOn(handle, "show"); + const hide = vi.spyOn(handle, "hide"); + const destroy = vi.spyOn(handle, "destroy"); + + update(fixture, (host) => { + host.onHandle = b.onHandle; + }); + expect(a.calls).toEqual([handle, null]); + expect(b.calls).toEqual([handle]); + expect(overlay()).toBe(element); + expect(replay).not.toHaveBeenCalled(); + expect(show).not.toHaveBeenCalled(); + expect(hide).not.toHaveBeenCalled(); + expect(destroy).not.toHaveBeenCalled(); + + // The new owner, not the old one, receives the teardown null. + fixture.destroy(); + expect(b.calls).toEqual([handle, null]); +}); + +it("still delivers the existing handle when the outgoing callback throws", () => { + const seen: (StetHandle | null)[] = []; + const outgoing: Callback = (handle) => { + if (handle === null) throw new Error("old callback failed"); + seen.push(handle); + }; + const next = collector(); + const fixture = create(CircleHost, (host) => { + host.options = { seed: 1 }; + host.onHandle = outgoing; + }); + const handle = seen[0]!; + + let error: unknown; + try { + update(fixture, (host) => { + host.onHandle = next.onHandle; + }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("old callback failed"); + // The new owner still receives the existing handle, then the teardown null. + expect(next.calls).toEqual([handle]); + fixture.destroy(); + expect(next.calls).toEqual([handle, null]); + expect(document.querySelector(".stet-overlay")).toBeNull(); +}); + +it("waits silently for an absent Arrow destination and reports null when it disappears", () => { + const { calls, onHandle } = collector(); + const destination = document.createElement("p"); + document.body.append(destination); + const fixture = create(ArrowHost, (host) => { + host.options = { to: undefined, seed: 1 }; + host.onHandle = onHandle; + }); + + // No attach and no null notification while the destination is absent. + expect(calls).toHaveLength(0); + expect(document.querySelector(".stet-overlay")).toBeNull(); + + update(fixture, (host) => { + host.options = { to: destination, seed: 1 }; + }); + expect(calls).toHaveLength(1); + const handle = calls[0]!; + expect(handle).not.toBeNull(); + + update(fixture, (host) => { + host.options = { to: null, seed: 1 }; + }); + expect(calls).toEqual([handle, null]); + expect(document.querySelector(".stet-overlay")).toBeNull(); + + // A reappearing destination attaches a fresh handle. + update(fixture, (host) => { + host.options = { to: destination, seed: 1 }; + }); + expect(calls).toHaveLength(3); + expect(calls[2]).not.toBe(handle); +}); + +it("replaces Arrow's destination, destroys the old handle, and moves owned ARIA", () => { + const { calls, onHandle } = collector(); + const first = document.createElement("p"); + const second = document.createElement("p"); + document.body.append(first, second); + const fixture = create(ArrowHost, (host) => { + host.options = { to: first, seed: 1, label: "One" }; + host.onHandle = onHandle; + }); + + const handle = calls[0]!; + const firstOverlay = overlay(); + expect(document.querySelector(".stet-label")?.textContent).toBe("One"); + expect(first.getAttribute("aria-describedby")).toContain("stet-description-"); + + // A label change updates in place; the handle is not recreated. + update(fixture, (host) => { + host.options = { to: first, seed: 1, label: "Two" }; + }); + expect(document.querySelector(".stet-label")?.textContent).toBe("Two"); + expect(calls).toEqual([handle]); + + update(fixture, (host) => { + host.options = { to: second, seed: 1, label: "Two" }; + }); + expect(calls[0]).toBe(handle); + expect(calls[1]).toBeNull(); + expect(calls[2]).not.toBe(handle); + expect(calls).toHaveLength(3); + expect(overlay()).not.toBe(firstOverlay); + expect(first.hasAttribute("aria-describedby")).toBe(false); + expect(second.getAttribute("aria-describedby")).toContain("stet-description-"); +}); + +it("replaces the host element, destroying the old handle and using declarative visibility", () => { + const { calls, onHandle } = collector(); + const fixture = create(SwapHost, (host) => { + host.options = { seed: 1, description: "D" }; + host.onHandle = onHandle; + }); + + const first = calls[0]!; + const oldNode = document.querySelector("button.host")!; + const firstOverlay = overlay(); + expect(oldNode.getAttribute("aria-describedby")).toContain("stet-description-"); + + // An imperative hide must not carry to the replacement. + first.hide(); + expect(overlay().hidden).toBe(true); + + update(fixture, (host) => { + host.tag = "a"; + }); + expect(calls[0]).toBe(first); + expect(calls[1]).toBeNull(); + expect(calls[2]).not.toBe(first); + expect(calls).toHaveLength(3); + expect(overlay()).not.toBe(firstOverlay); + expect(overlay().hidden).toBe(false); + expect(oldNode.hasAttribute("aria-describedby")).toBe(false); + expect(document.querySelector("a.host")!.getAttribute("aria-describedby")).toContain( + "stet-description-", + ); +}); + +it("destroys and nulls the handle when control flow removes the host", () => { + const { calls, onHandle } = collector(); + const fixture = create(SwapHost, (host) => { + host.options = { seed: 1 }; + host.onHandle = onHandle; + }); + const handle = calls[0]!; + + update(fixture, (host) => { + host.present = false; + }); + expect(calls).toEqual([handle, null]); + expect(document.querySelector(".stet-overlay")).toBeNull(); +}); + +it("applies changed animation options to the next entrance, not as an automatic replay", async () => { + const { calls, onHandle } = collector(); + const fixture = create(CircleHost, (host) => { + host.options = { seed: 1 }; + host.onHandle = onHandle; + }); + const handle = calls[0]!; + const replay = vi.spyOn(handle, "replay"); + const path = () => document.querySelector(".stet-svg path")!; + + // No animation configured: replay leaves the drawing at its final frame. + await handle.replay(); + expect(path().getAttribute("stroke-dashoffset")).toBeNull(); + replay.mockClear(); + + update(fixture, (host) => { + host.options = { seed: 1, animate: true, animationDuration: 600 }; + }); + // The option change updates runtime state without replaying by itself. + expect(replay).not.toHaveBeenCalled(); + + // The next entrance primes the path-length reveal. + const entrance = handle.replay(); + expect(parseFloat(path().getAttribute("stroke-dashoffset") ?? "0")).toBeGreaterThan(0); + fixture.destroy(); + await entrance; +}); diff --git a/tests/browser/reveal.spec.ts b/tests/browser/reveal.spec.ts index ea5c2ce..3f459df 100644 --- a/tests/browser/reveal.spec.ts +++ b/tests/browser/reveal.spec.ts @@ -9,7 +9,7 @@ test.beforeEach(async ({ page }) => { }); }); -type Primitive = "circle" | "underline"; +type Primitive = "box" | "circle" | "underline"; async function attach( page: Page, @@ -23,8 +23,8 @@ async function attach( target.id = "target"; target.style.cssText = `position:absolute;left:40px;top:${top}px;width:140px;height:40px`; document.body.append(target); - const { circle, underline } = await import("/dist/index.js"); - (window as any).stet = (primitive === "circle" ? circle : underline)(target, { + const { box, circle, underline } = await import("/dist/index.js"); + (window as any).stet = { box, circle, underline }[primitive](target, { seed: 1, ...options, }); @@ -45,7 +45,7 @@ async function attach( const path = (page: Page, primitive: Primitive = "underline") => page.locator(`.stet-overlay--${primitive} path`).first(); -for (const primitive of ["circle", "underline"] as const) { +for (const primitive of ["box", "circle", "underline"] as const) { test(`${primitive} reveals with path-length dashes, no flash and no opacity`, async ({ page, }) => { @@ -162,7 +162,7 @@ test("a detached reveal completes and reconnects at its final frame", async ({ p expect(await overlay.locator("path").first().getAttribute("stroke-dashoffset")).toBeNull(); }); -for (const primitive of ["circle", "underline"] as const) { +for (const primitive of ["box", "circle", "underline"] as const) { test(`${primitive} settles immediately under initial reduced motion`, async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); const initial = await attach(page, { animate: true, animationDuration: 800 }, 350, primitive); @@ -343,7 +343,7 @@ test("reduced motion yields one stable variant for deterministic capture", async expect(await page.evaluate(() => document.getAnimations().length)).toBe(0); }); -for (const primitive of ["circle", "underline"] as const) { +for (const primitive of ["box", "circle", "underline"] as const) { test(`${primitive} show joins an in-flight reveal with the same promise object`, async ({ page, }) => { diff --git a/tests/browser/visual.spec.ts b/tests/browser/visual.spec.ts index 28d1e3c..5bd2a00 100644 --- a/tests/browser/visual.spec.ts +++ b/tests/browser/visual.spec.ts @@ -7,15 +7,33 @@ test.use({ launchOptions: { headless: true } }); test.beforeEach(async ({ page }) => { await page.goto("/examples/visual/"); - await page.addStyleTag({ url: '/tests/browser/fonts/fixture.css' }); + await page.addStyleTag({ url: "/tests/browser/fonts/fixture.css" }); await page.waitForFunction(() => "handles" in window); - await page.evaluate(async () => { await document.fonts.ready; (window as any).handles.forEach((handle: any) => handle.refresh()); }); + await page.evaluate(async () => { + await document.fonts.ready; + (window as any).handles.forEach((handle: any) => handle.refresh()); + }); }); test("fixed seeds preserve the visual language", async ({ page }) => { await expect(page).toHaveScreenshot("specimens.png", { animations: "disabled" }); }); +test("rough box geometry reads as one continuous perimeter gesture", async ({ page }) => { + await page.evaluate(async () => { + const { roughBox } = await import("/dist/rough.js"); + document.body.innerHTML = ` + + + + `; + }); + + await expect(page.locator("#box-geometry")).toHaveScreenshot("box-geometry.png", { + animations: "disabled", + }); +}); + test("mobile notes follow visible targets and stay in the viewport", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); const note = page.locator(".stet-overlay--sticky"); @@ -35,9 +53,13 @@ test("nested scrolling hides clipped targets and restores their marks", async ({ await target.scrollIntoViewIfNeeded(); const overlay = page.locator(".stet-overlay--circle").nth(5); await expect(overlay).toBeVisible(); - await page.locator(".scroller").evaluate(node => { node.scrollTop = 200; }); + await page.locator(".scroller").evaluate((node) => { + node.scrollTop = 200; + }); await expect(overlay).toBeHidden(); - await page.locator(".scroller").evaluate(node => { node.scrollTop = 0; }); + await page.locator(".scroller").evaluate((node) => { + node.scrollTop = 0; + }); await expect(overlay).toBeVisible(); }); @@ -54,7 +76,11 @@ test("native input, focus, cleanup, and accessible descriptions survive", async test("reduced motion responds while the page is open", async ({ page }) => { await page.evaluate(async () => { const { circle } = await import("/dist/index.js"); - (window as any).motionHandle = circle(document.querySelector("#button")!, { seed: 5, boil: 0.3, resketchOnHover: true }); + (window as any).motionHandle = circle(document.querySelector("#button")!, { + seed: 5, + boil: 0.3, + resketchOnHover: true, + }); }); await expect(page.locator(".stet-boil")).toHaveCount(3); await page.emulateMedia({ reducedMotion: "reduce" }); @@ -66,7 +92,9 @@ test("reduced motion responds while the page is open", async ({ page }) => { await expect(page.locator(".stet-boil")).toHaveCount(3); }); -test("refresh follows movement without reseeding; default drawings stay still", async ({ page }) => { +test("refresh follows movement without reseeding; default drawings stay still", async ({ + page, +}) => { const overlay = page.locator(".stet-overlay--circle").first(); const before = await overlay.locator("path").getAttribute("d"); const x = (await overlay.boundingBox())!.x; @@ -81,12 +109,21 @@ test("refresh follows movement without reseeding; default drawings stay still", test("forced colors retain distinct marks and readable notes", async ({ page }) => { await page.emulateMedia({ forcedColors: "active" }); - const stroke = await page.locator(".stet-mark--right").evaluate(node => getComputedStyle(node).stroke); - expect(stroke).toBe(await page.locator(".stet-circle").first().evaluate(node => getComputedStyle(node).stroke)); + const stroke = await page + .locator(".stet-mark--right") + .evaluate((node) => getComputedStyle(node).stroke); + expect(stroke).toBe( + await page + .locator(".stet-circle") + .first() + .evaluate((node) => getComputedStyle(node).stroke), + ); await expect(page).toHaveScreenshot("forced-colors.png"); }); -test("mixed text produces one band per line, and long notes grow with their text", async ({ page }) => { +test("mixed text produces one band per line, and long notes grow with their text", async ({ + page, +}) => { const mixed = page.locator(".stet-overlay--highlight").last(); await page.locator("#mixed").scrollIntoViewIfNeeded(); await expect(mixed.locator(".stet-highlight:not(.stet-highlight-edge)")).toHaveCount(1); @@ -98,7 +135,12 @@ test("mixed text produces one band per line, and long notes grow with their text }); const root = document.querySelector(".stet-overlay:last-child")!; const text = root.querySelector(".stet-sticky-text")!; - const dimensions = { height: root.offsetHeight, textHeight: text.offsetHeight, scroll: text.scrollHeight, client: text.clientHeight }; + const dimensions = { + height: root.offsetHeight, + textHeight: text.offsetHeight, + scroll: text.scrollHeight, + client: text.clientHeight, + }; handle.destroy(); return dimensions; }); @@ -111,9 +153,13 @@ test("short vertical arrows place labels beside the anchors", async ({ page }) = await page.setViewportSize({ width: 390, height: 844 }); const box = await page.evaluate(async () => { (window as any).handles.forEach((handle: any) => handle.destroy()); - document.body.innerHTML = '

Heading

'; + document.body.innerHTML = + '

Heading

'; const { arrow } = await import("/dist/index.js"); - arrow(document.querySelector("#a")!, document.querySelector("#b")!, { label: "poke the cache", seed: 1 }); + arrow(document.querySelector("#a")!, document.querySelector("#b")!, { + label: "poke the cache", + seed: 1, + }); return document.querySelector(".stet-label")!.getBoundingClientRect().toJSON(); }); expect(box.left).toBeGreaterThan(270); diff --git a/tests/browser/visual.spec.ts-snapshots/box-geometry-chromium-linux.png b/tests/browser/visual.spec.ts-snapshots/box-geometry-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..d1ba597140e8560fe35d1ea1edff85694c3483ca GIT binary patch literal 3448 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 literal 0 HcmV?d00001 diff --git a/tests/pw01/build-payload.mjs b/tests/pw01/build-payload.mjs new file mode 100644 index 0000000..6efecca --- /dev/null +++ b/tests/pw01/build-payload.mjs @@ -0,0 +1,27 @@ +// Test-only representative payload for the PW-01 loading investigation. +// Bundles the real renderer exactly as a future packed Playwright payload would, +// and hands it to the page under a symbol so no bare global collides with the page. +import { build } from "esbuild"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); +const outdir = `${root}test-results/pw01`; +mkdirSync(outdir, { recursive: true }); + +const result = await build({ + entryPoints: [`${root}src/index.ts`], + bundle: true, + format: "iife", + globalName: "__stetPw01Bundle", + target: "es2020", + write: false, + legalComments: "none", +}); + +const code = result.outputFiles[0].text; +// The wrapper keeps the esbuild global function-scoped; the only page handoff is +// the symbol, which cannot collide with a source-authored global. +const wrapped = `(() => {\n${code}\nglobalThis[Symbol.for("funsaized.stet.pw01.payload")] = __stetPw01Bundle;\n})();\n`; +writeFileSync(`${outdir}/payload.js`, wrapped); +console.log(`pw01 payload written: ${wrapped.length} bytes`); diff --git a/tests/pw01/fixtures/authored.js b/tests/pw01/fixtures/authored.js new file mode 100644 index 0000000..4cab430 --- /dev/null +++ b/tests/pw01/fixtures/authored.js @@ -0,0 +1,11 @@ +// Source-authored Stet: a normal page module using the package ESM entry. +import { circle } from "/dist/index.js"; + +const target = document.getElementById("authored"); +const handle = circle(target, { seed: 42, stroke: "#123456" }); +const overlay = document.querySelector("#authored ~ .stet-overlay--circle, .stet-overlay--circle"); +const path = overlay?.querySelector("path") ?? null; +overlay?.setAttribute("data-authored", "1"); + +// Captured references the helper must not disturb. +globalThis.__authored = { handle, overlay, path, d: path?.getAttribute("d") ?? null }; diff --git a/tests/pw01/fixtures/base.css b/tests/pw01/fixtures/base.css new file mode 100644 index 0000000..7c5cb5e --- /dev/null +++ b/tests/pw01/fixtures/base.css @@ -0,0 +1,48 @@ +/* Neutral page styles for the PW-01 fixtures. Not the Stet stylesheet. */ +html, +body { + margin: 0; + padding: 0; + min-height: 600px; +} + +.target { + position: absolute; + left: 40px; + width: 160px; + height: 40px; + background: #dde5dd; +} + +#target, +#outer-target, +#frame-target { + top: 60px; +} + +#authored { + top: 160px; +} + +#helper-target { + top: 280px; +} + +/* A "foreign" annotation with its own stylesheet identity and resource. */ +.foreign-annotation { + position: absolute; + top: 400px; + left: 40px; + width: 160px; + height: 40px; + outline: 5px solid rgb(10, 20, 30); + background: rgb(1, 2, 3); +} + +#frame { + position: absolute; + top: 520px; + left: 40px; + width: 400px; + height: 120px; +} diff --git a/tests/pw01/fixtures/blocked.html b/tests/pw01/fixtures/blocked.html new file mode 100644 index 0000000..22c1018 --- /dev/null +++ b/tests/pw01/fixtures/blocked.html @@ -0,0 +1,14 @@ + + + + + + PW-01 blocked CSP + + + + + diff --git a/tests/pw01/fixtures/coexist.html b/tests/pw01/fixtures/coexist.html new file mode 100644 index 0000000..e86e5d0 --- /dev/null +++ b/tests/pw01/fixtures/coexist.html @@ -0,0 +1,21 @@ + + + + + + + + + + + PW-01 coexistence + + + + +
+ + diff --git a/tests/pw01/fixtures/foreign.css b/tests/pw01/fixtures/foreign.css new file mode 100644 index 0000000..1808e33 --- /dev/null +++ b/tests/pw01/fixtures/foreign.css @@ -0,0 +1,4 @@ +/* Foreign annotations keep their own resource and must survive Stet loading. */ +.foreign-annotation { + outline-color: rgb(10, 20, 30); +} diff --git a/tests/pw01/fixtures/frame-outer.html b/tests/pw01/fixtures/frame-outer.html new file mode 100644 index 0000000..31066e1 --- /dev/null +++ b/tests/pw01/fixtures/frame-outer.html @@ -0,0 +1,16 @@ + + + + + + + PW-01 same-origin frame outer + + + + + + diff --git a/tests/pw01/fixtures/open.html b/tests/pw01/fixtures/open.html new file mode 100644 index 0000000..fcfbf20 --- /dev/null +++ b/tests/pw01/fixtures/open.html @@ -0,0 +1,11 @@ + + + + + + PW-01 open (no CSP) + + + + + diff --git a/tests/pw01/fixtures/strict-inner.html b/tests/pw01/fixtures/strict-inner.html new file mode 100644 index 0000000..2280f2c --- /dev/null +++ b/tests/pw01/fixtures/strict-inner.html @@ -0,0 +1,15 @@ + + + + + + + PW-01 same-origin frame inner + + + + + diff --git a/tests/pw01/fixtures/strict.html b/tests/pw01/fixtures/strict.html new file mode 100644 index 0000000..48f8c6f --- /dev/null +++ b/tests/pw01/fixtures/strict.html @@ -0,0 +1,16 @@ + + + + + + + PW-01 strict CSP + + + + foreign annotation + + diff --git a/tests/pw01/global-setup.ts b/tests/pw01/global-setup.ts new file mode 100644 index 0000000..33de83b --- /dev/null +++ b/tests/pw01/global-setup.ts @@ -0,0 +1,14 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +// Narrow build prep for the investigation: +// - emit the package's own ESM dist that the source-authored fixture imports +// - bundle the test-only representative Playwright payload from src +export default function globalSetup(): void { + const root = fileURLToPath(new URL("../../", import.meta.url)); + execFileSync(process.execPath, [`${root}node_modules/typescript/bin/tsc`, "-p", `${root}tsconfig.json`], { + cwd: root, + stdio: "inherit", + }); + execFileSync(process.execPath, [`${root}tests/pw01/build-payload.mjs`], { cwd: root, stdio: "inherit" }); +} diff --git a/tests/pw01/loading.spec.ts b/tests/pw01/loading.spec.ts new file mode 100644 index 0000000..8c3fc03 --- /dev/null +++ b/tests/pw01/loading.spec.ts @@ -0,0 +1,224 @@ +import { expect, test, type Frame, type Page } from "@playwright/test"; +import { + attachCircle, + destroyAttached, + hasPayload, + loadInline, + loadViaUrl, + markViaEvaluate, + ROUTE_STYLE_URL, +} from "./stet-loader"; + +const STRICT = "/tests/pw01/fixtures/strict.html"; +const OPEN = "/tests/pw01/fixtures/open.html"; +const BLOCKED = "/tests/pw01/fixtures/blocked.html"; +const COEXIST = "/tests/pw01/fixtures/coexist.html"; +const FRAME_OUTER = "/tests/pw01/fixtures/frame-outer.html"; + +async function annotationStyle(target: Page | Frame) { + return target.evaluate(() => { + const path = document.querySelector(".stet-overlay--circle path"); + if (!path) throw new Error("no circle path in document"); + const style = getComputedStyle(path); + const overlay = getComputedStyle(path.closest(".stet-overlay") as Element); + return { + stroke: style.stroke, + strokeWidth: style.strokeWidth, + pointerEvents: overlay.pointerEvents, + }; + }); +} + +test.describe("transport comparison", () => { + for (const variant of ["static", "route"] as const) { + test(`strict CSP: ${variant} same-origin URL transport loads the real renderer and stylesheet`, async ({ + page, + }) => { + await page.goto(STRICT); + const foreignBefore = await page.locator("#foreign").evaluate((element) => { + const style = getComputedStyle(element); + return { background: style.backgroundColor, outline: style.outline, position: style.position }; + }); + const loaded = await loadViaUrl(page, page, { variant }); + expect(loaded.violations).toEqual([]); + expect(await hasPayload(page)).toBe(true); + expect(await attachCircle(page, "target", 7)).toBe(true); + await expect(page.locator(".stet-overlay--circle")).toHaveCount(1); + + const style = await annotationStyle(page); + // Values below can only come from style.css; the renderer sets no inline stroke. + expect(style.stroke).toBe("rgb(201, 42, 42)"); + expect(style.strokeWidth).toBe("2.2px"); + expect(style.pointerEvents).toBe("none"); + expect( + await page.locator("#foreign").evaluate((element) => { + const style = getComputedStyle(element); + return { background: style.backgroundColor, outline: style.outline, position: style.position }; + }), + ).toEqual(foreignBefore); + // The symbol handoff left no bare bundle global on the page. + expect(await page.evaluate(() => "__stetPw01Bundle" in globalThis)).toBe(false); + + await loaded.cleanup(); + expect(await hasPayload(page)).toBe(false); + }); + } + + test("strict CSP: inline content transport is blocked by page policy", async ({ page }) => { + await page.goto(STRICT); + let failed = false; + try { + await loadInline(page, page); + } catch { + failed = true; + } + expect(failed).toBe(true); + expect(await hasPayload(page)).toBe(false); + await expect(page.locator(".stet-overlay--circle")).toHaveCount(0); + }); + + test("open page: the same inline content transport works (baseline), so the failure is CSP", async ({ + page, + }) => { + await page.goto(OPEN); + const loaded = await loadInline(page, page); + expect(await hasPayload(page)).toBe(true); + expect(await attachCircle(page, "target", 3)).toBe(true); + await expect(page.locator(".stet-overlay--circle")).toHaveCount(1); + await loaded.cleanup(); + }); + + test("blocked CSP: URL transport fails detectably and rolls back", async ({ page }) => { + await page.goto(BLOCKED); + let failure: unknown; + try { + await loadViaUrl(page, page, { variant: "route" }); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(await hasPayload(page)).toBe(false); + const leftover = await page.evaluate(() => ({ + scripts: document.querySelectorAll('script[src*="__stet__"]').length, + links: document.querySelectorAll('link[href*="__stet__"]').length, + overlays: document.querySelectorAll(".stet-overlay").length, + })); + expect(leftover).toEqual({ scripts: 0, links: 0, overlays: 0 }); + // Rejection is portable. Firefox does not expose the CSP violation through + // the page console, so classification remains a PW-02 packaging question. + }); + + test("blocked CSP: evaluate can run script despite policy (rejected mechanism, recorded)", async ({ + page, + }) => { + await page.goto(BLOCKED); + await markViaEvaluate(page); + expect(await hasPayload(page)).toBe(true); + }); +}); + +test.describe("same-origin frame", () => { + test("strict CSP: route URL transport loads and draws inside a same-origin frame", async ({ page }) => { + await page.goto(FRAME_OUTER); + const frame = page.frames().find((candidate) => candidate.url().includes("strict-inner")); + expect(frame, "same-origin frame must be present").toBeTruthy(); + await frame!.waitForLoadState(); + + const loaded = await loadViaUrl(frame!, page, { variant: "route" }); + expect(loaded.violations).toEqual([]); + expect(await attachCircle(frame!, "frame-target", 11)).toBe(true); + + await expect(page.frameLocator("#frame").locator(".stet-overlay--circle")).toHaveCount(1); + await expect(page.locator("#outer-target ~ .stet-overlay--circle, body > .stet-overlay--circle")).toHaveCount(0); + const style = await annotationStyle(frame!); + expect(style.stroke).toBe("rgb(201, 42, 42)"); + expect(style.pointerEvents).toBe("none"); + // Resource request travelled to the route URL relative to the frame document. + expect(await frame!.evaluate((marker) => performance.getEntriesByType("resource").some((entry) => entry.name.includes(marker)), ROUTE_STYLE_URL)).toBe(true); + await loaded.cleanup(); + }); +}); + +test.describe("coexistence and ownership", () => { + test("strict CSP: injection does not alter source-authored or foreign annotations or remove their resources", async ({ + page, + }) => { + await page.goto(COEXIST); + await page.waitForFunction(() => Boolean((globalThis as Record).__authored)); + + const snapshot = () => + page.evaluate(() => { + const authored = (globalThis as Record).__authored as { + overlay: Element; + path: Element; + d: string | null; + }; + const foreign = getComputedStyle(document.getElementById("foreign") as Element); + const authoredPath = getComputedStyle(authored.path); + return { + authoredExists: authored.overlay.isConnected, + authoredD: authored.path.getAttribute("d"), + authoredStroke: authoredPath.stroke, + authoredStrokeWidth: authoredPath.strokeWidth, + foreignOutline: foreign.outlineColor, + foreignBackground: foreign.backgroundColor, + authoredStyleLink: document.querySelectorAll('link[href="/style.css"]').length, + foreignStyleLink: document.querySelectorAll('link[href="/tests/pw01/fixtures/foreign.css"]').length, + authoredPayloadScripts: document.querySelectorAll('script[src*="/test-results/pw01/payload.js"]').length, + }; + }); + + const before = await snapshot(); + expect(before.authoredExists).toBe(true); + + const loaded = await loadViaUrl(page, page, { variant: "route" }); + expect(await attachCircle(page, "helper-target", 99)).toBe(true); + await expect(page.locator(".stet-overlay--circle")).toHaveCount(2); + + const after = await snapshot(); + expect(after.authoredExists).toBe(true); + expect(after.authoredD).toBe(before.authoredD); + expect(after.authoredStroke).toBe(before.authoredStroke); + expect(after.authoredStrokeWidth).toBe(before.authoredStrokeWidth); + expect(after.foreignOutline).toBe(before.foreignOutline); + expect(after.foreignBackground).toBe(before.foreignBackground); + expect(after.authoredStyleLink).toBe(before.authoredStyleLink); + expect(after.foreignStyleLink).toBe(before.foreignStyleLink); + expect(after.authoredPayloadScripts).toBe(before.authoredPayloadScripts); + + await destroyAttached(page); + await loaded.cleanup(); + const afterCleanup = await snapshot(); + // The helper disposed only its own annotation and resources. + expect(afterCleanup.authoredExists).toBe(true); + expect(afterCleanup.authoredD).toBe(before.authoredD); + expect(afterCleanup.authoredStroke).toBe(before.authoredStroke); + expect(afterCleanup.authoredStyleLink).toBe(before.authoredStyleLink); + expect(afterCleanup.foreignStyleLink).toBe(before.foreignStyleLink); + await expect(page.locator(".stet-overlay--circle")).toHaveCount(1); + }); +}); + +test.describe("rollback after partial load failure", () => { + for (const fail of ["css", "js"] as const) { + test(`strict CSP: partial ${fail.toUpperCase()} network failure leaves no injected resources`, async ({ + page, + }) => { + await page.goto(STRICT); + let failed = false; + try { + await loadViaUrl(page, page, { variant: "route", fail }); + } catch { + failed = true; + } + expect(failed).toBe(true); + expect(await hasPayload(page)).toBe(false); + const leftover = await page.evaluate(() => ({ + scripts: document.querySelectorAll('script[src*="__stet__"]').length, + links: document.querySelectorAll('link[href*="__stet__"]').length, + overlays: document.querySelectorAll(".stet-overlay").length, + })); + expect(leftover).toEqual({ scripts: 0, links: 0, overlays: 0 }); + }); + } +}); diff --git a/tests/pw01/stet-loader.ts b/tests/pw01/stet-loader.ts new file mode 100644 index 0000000..83624c2 --- /dev/null +++ b/tests/pw01/stet-loader.ts @@ -0,0 +1,215 @@ +// PW-01 investigation helpers. Test-only; no production export is added. +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { ConsoleMessage, ElementHandle, Frame, Page } from "@playwright/test"; + +export type Target = Page | Frame; +export type Transport = "inline" | "url-static" | "url-route"; + +export const PAYLOAD_SYMBOL = "funsaized.stet.pw01.payload"; +export const PAYLOAD_FILE = fileURLToPath(new URL("../../test-results/pw01/payload.js", import.meta.url)); +export const STYLE_FILE = fileURLToPath(new URL("../../style.css", import.meta.url)); +export const STATIC_PAYLOAD_URL = "/test-results/pw01/payload.js"; +export const STATIC_STYLE_URL = "/style.css"; +const ROUTE_MARKER = "/__stet__/"; +export const ROUTE_PAYLOAD_URL = `${ROUTE_MARKER}payload.js`; +export const ROUTE_STYLE_URL = `${ROUTE_MARKER}style.css`; + +const READY_TIMEOUT_MS = 3000; + +export interface Loaded { + transport: Transport; + violations: string[]; + cleanup(): Promise; +} + +function cspListener(violations: string[]) { + return (message: ConsoleMessage) => { + if (/content security policy|refused to (?:load|apply|execute)/i.test(message.text())) + violations.push(message.text()); + }; +} + +function withTimeout(promise: Promise, label: string): Promise { + // Keep an unhandled rejection from surfacing after the timeout wins the race. + promise.catch(() => {}); + let timer: ReturnType; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${READY_TIMEOUT_MS}ms`)), READY_TIMEOUT_MS); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +export async function waitForPayload(target: Target): Promise { + await target.waitForFunction( + (key) => typeof (globalThis as Record)[Symbol.for(key)] === "object", + PAYLOAD_SYMBOL, + { timeout: READY_TIMEOUT_MS }, + ); + return true; +} + +export async function hasPayload(target: Target): Promise { + return target.evaluate((key) => typeof (globalThis as Record)[Symbol.for(key)] === "object", PAYLOAD_SYMBOL); +} + +async function installRoutes(page: Page): Promise { + const context = page.context(); + await context.route("**/__stet__/payload.js", (route) => + route.fulfill({ contentType: "text/javascript", body: readFileSync(PAYLOAD_FILE, "utf8") }), + ); + await context.route("**/__stet__/style.css", (route) => + route.fulfill({ contentType: "text/css", body: readFileSync(STYLE_FILE, "utf8") }), + ); +} + +async function removeRoutes(page: Page): Promise { + const context = page.context(); + await context.unroute("**/__stet__/payload.js"); + await context.unroute("**/__stet__/style.css"); +} + +async function deletePayload(target: Target): Promise { + await target + .evaluate((key) => { + delete (globalThis as Record)[Symbol.for(key)]; + }, PAYLOAD_SYMBOL) + .catch(() => {}); +} + +/** Remove only the resources this investigation injected (never authored ones). */ +async function sweepInjected(target: Target, marker: string): Promise { + await target + .evaluate((needle) => { + for (const node of Array.from(document.querySelectorAll("link[href],script[src]"))) { + const ref = node.getAttribute("href") ?? node.getAttribute("src") ?? ""; + if (ref.includes(needle)) node.remove(); + } + }, marker) + .catch(() => {}); +} + +export interface UrlOptions { + variant: "static" | "route"; + /** Simulated partial failure for rollback evidence. */ + fail?: "js" | "css"; +} + +export async function loadViaUrl(target: Target, page: Page, options: UrlOptions): Promise { + const violations: string[] = []; + const listener = cspListener(violations); + page.on("console", listener); + const routeMode = options.variant === "route"; + const marker = routeMode ? ROUTE_MARKER : "/test-results/pw01/"; + if (routeMode) { + await installRoutes(page); + if (options.fail === "js") + await page.context().route("**/__stet__/payload.js", (route) => route.abort("failed")); + if (options.fail === "css") + await page.context().route("**/__stet__/style.css", (route) => route.abort("failed")); + } + let css: ElementHandle | null = null; + let js: ElementHandle | null = null; + try { + css = await withTimeout( + target.addStyleTag({ url: routeMode ? ROUTE_STYLE_URL : STATIC_STYLE_URL }), + "addStyleTag", + ); + js = await withTimeout( + target.addScriptTag({ url: routeMode ? ROUTE_PAYLOAD_URL : STATIC_PAYLOAD_URL }), + "addScriptTag", + ); + await waitForPayload(target); + return { + transport: routeMode ? "url-route" : "url-static", + violations, + async cleanup() { + await sweepInjected(target, marker); + await css?.evaluate((node) => node.remove()).catch(() => {}); + await js?.evaluate((node) => node.remove()).catch(() => {}); + await deletePayload(target); + if (routeMode) await removeRoutes(page); + page.off("console", listener); + }, + }; + } catch (error) { + await sweepInjected(target, marker); + await css?.evaluate((node) => node.remove()).catch(() => {}); + if (routeMode) await removeRoutes(page); + page.off("console", listener); + throw error; + } +} + +export async function loadInline(target: Target, page: Page): Promise { + const violations: string[] = []; + const listener = cspListener(violations); + page.on("console", listener); + let css: ElementHandle | null = null; + let js: ElementHandle | null = null; + try { + css = await withTimeout( + target.addStyleTag({ content: readFileSync(STYLE_FILE, "utf8") }), + "inline addStyleTag", + ); + js = await withTimeout( + target.addScriptTag({ content: readFileSync(PAYLOAD_FILE, "utf8") }), + "inline addScriptTag", + ); + await waitForPayload(target); + return { + transport: "inline", + violations, + async cleanup() { + await css?.evaluate((node) => node.remove()).catch(() => {}); + await js?.evaluate((node) => node.remove()).catch(() => {}); + await deletePayload(target); + page.off("console", listener); + }, + }; + } catch (error) { + await css?.evaluate((node) => node.remove()).catch(() => {}); + page.off("console", listener); + throw error; + } +} + +/** Deliberate CSP bypass probe. Kept only to document why evaluate is rejected. */ +export async function markViaEvaluate(target: Target): Promise { + await target.evaluate((key) => { + (globalThis as Record)[Symbol.for(key)] = { injectedBy: "evaluate" }; + }, PAYLOAD_SYMBOL); +} + +const HANDLE_SYMBOL = "funsaized.stet.pw01.handle"; + +/** Read the payload through a remote call, as a Playwright helper would. */ +export async function attachCircle(target: Target, elementId: string, seed: number): Promise { + return target.evaluate( + ({ key, handleKey, elementId, seed }) => { + const payload = (globalThis as Record)[Symbol.for(key)] as { + circle(element: Element, options: { seed: number }): unknown; + }; + const element = document.getElementById(elementId); + if (!element) throw new Error(`missing target ${elementId}`); + (globalThis as Record)[Symbol.for(handleKey)] = payload.circle(element, { seed }); + return true; + }, + { key: PAYLOAD_SYMBOL, handleKey: HANDLE_SYMBOL, elementId, seed }, + ); +} + +/** Emulate helper-owned handle disposal; must not touch authored overlays. */ +export async function destroyAttached(target: Target): Promise { + await target.evaluate((handleKey) => { + const handle = (globalThis as Record)[Symbol.for(handleKey)] as { + destroy(): void; + }; + handle?.destroy(); + delete (globalThis as Record)[Symbol.for(handleKey)]; + }, HANDLE_SYMBOL); +} + +export async function overlayCount(target: Target, className: string): Promise { + return target.evaluate((name) => document.querySelectorAll(`.${name}`).length, className); +} diff --git a/tests/pw02/build-payload.mjs b/tests/pw02/build-payload.mjs new file mode 100644 index 0000000..98cc0ac --- /dev/null +++ b/tests/pw02/build-payload.mjs @@ -0,0 +1,51 @@ +// PW-02 proposed payload build experiment. Test-only; output goes to the +// gitignored `dist/playwright/` directory so `npm pack` (which ships `dist`) +// carries it into the tarball without any package.json/exports change. +import assert from "node:assert/strict"; +import { copyFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); +const outdir = `${root}dist/playwright`; +const evidence = `${root}test-results/pw02`; +rmSync(outdir, { recursive: true, force: true }); +mkdirSync(outdir, { recursive: true }); +mkdirSync(evidence, { recursive: true }); + +const result = await build({ + entryPoints: [`${root}src/index.ts`], + bundle: true, + format: "iife", + globalName: "__stetPw02Bundle", + target: "es2020", + write: false, + legalComments: "none", + metafile: true, +}); + +const inputs = Object.keys(result.metafile.inputs).map((path) => path.replaceAll("\\", "/")); +assert( + inputs.every((path) => /(^|\/)src\//.test(path) && !path.includes("node_modules")), + `payload graph must contain only browser source: ${inputs.join(", ")}`, +); +assert( + inputs.every((path) => !/node:|@playwright/.test(path)), + `payload graph leaked Node/Playwright code: ${inputs.filter((path) => /node:|@playwright/.test(path)).join(", ")}`, +); + +const code = result.outputFiles[0].text; +// The wrapper keeps the esbuild global function-scoped and hands the page only a +// symbol. Re-loading the payload (a second session) reuses the existing runtime +// and handle map instead of clobbering another session's handles. +const wrapped = + `(() => {\n${code}\n` + + `const __stetKey = Symbol.for(${JSON.stringify("funsaized.stet.playwright.payload")});\n` + + `globalThis[__stetKey] = globalThis[__stetKey] || { runtime: __stetPw02Bundle, handles: new Map(), next: 1 };\n` + + `})();\n`; +writeFileSync(`${outdir}/payload.js`, wrapped); +copyFileSync(`${root}tests/pw02/emulated-entry.mjs`, `${outdir}/entry.mjs`); +writeFileSync(`${evidence}/payload-graph.json`, JSON.stringify({ inputs: inputs.sort() }, null, 2)); +console.log( + JSON.stringify({ ok: true, payloadBytes: wrapped.length, payloadInputs: inputs.sort() }, null, 2), +); diff --git a/tests/pw02/emulated-entry.mjs b/tests/pw02/emulated-entry.mjs new file mode 100644 index 0000000..9e8a4b4 --- /dev/null +++ b/tests/pw02/emulated-entry.mjs @@ -0,0 +1,218 @@ +// PW-02 experiment only. This file emulates the future `@funsaized/stet/playwright` +// entry. `tests/pw02/build-payload.mjs` copies it to `dist/playwright/entry.mjs` +// inside the packed package; it is not exported and does not change the public API. +// +// Mechanism selected by PW-01: a prebuilt classic/IIFE payload plus the package +// stylesheet served from same-origin URLs fulfilled with package-local file +// contents. Discovery must be portable, so both assets are located relative to +// this module's own installed URL. No CDN, no consumer bundler, no Playwright +// import (callers pass their own Playwright objects). +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const PAYLOAD_FILE = fileURLToPath(new URL("./payload.js", import.meta.url)); +const STYLE_FILE = fileURLToPath(new URL("../../style.css", import.meta.url)); +export const PAYLOAD_SYMBOL = "funsaized.stet.playwright.payload"; + +export class StetPlaywrightError extends Error { + constructor(code, message, options) { + super(message, options); + this.name = "StetPlaywrightError"; + this.code = code; + } +} + +const isPage = (value) => typeof value?.mainFrame === "function"; + +// Shared browser-payload realm per page. The IIFE singleton is reused across +// sessions; the last session to dispose drops it. PW-02 records this lifetime as +// evidence for the still-gated loader-resource decision. +const realms = new WeakMap(); + +function classify(error, session) { + const text = String(error?.message ?? error); + let code = "BROWSER_OPERATION"; + if (/execution context was destroyed|most likely because of a navigation|navigat/i.test(text)) + code = "NAVIGATION"; + else if (/page has been closed|context or browser has been closed|browser has been closed/i.test(text)) + code = "PAGE_CLOSED"; + else if (/frame (was |has been )?detached/i.test(text)) code = "FRAME_DETACHED"; + if (code !== "BROWSER_OPERATION") session?.invalidate?.(); + return new StetPlaywrightError(code, `stet playwright: ${text}`, { cause: error }); +} + +let sequence = 0; + +/** + * @param {import("@playwright/test").Page | import("@playwright/test").Frame} context + * @param {{ abort?: "payload" | "style" }} [experiment] PW-02-only fault injection. + */ +export async function createStet(context, experiment = {}) { + const page = isPage(context) ? context : context.page(); + if (!page) throw new StetPlaywrightError("UNSUPPORTED_CONTEXT", "stet playwright: unsupported context"); + const browserContext = page.context(); + const id = `s${++sequence}`; + const marker = `/__stet__/${id}/`; + const payloadPath = `${marker}payload.js`; + const stylePath = `${marker}style.css`; + + // Read at session creation time. This is the discovery step: both files were + // located from the installed package, never from the repository or a CDN. + const payloadBody = readFileSync(PAYLOAD_FILE, "utf8"); + const styleBody = readFileSync(STYLE_FILE, "utf8"); + + let invalidated = false; + let disposed = false; + let acquired = false; + const injected = []; + const attached = []; + const routes = []; + + const installRoutes = async () => { + await browserContext.route(`**${marker}payload.js`, (route) => + experiment.abort === "payload" + ? route.abort("failed") + : route.fulfill({ contentType: "text/javascript", body: payloadBody }), + ); + routes.push(`**${marker}payload.js`); + await browserContext.route(`**${marker}style.css`, (route) => + experiment.abort === "style" + ? route.abort("failed") + : route.fulfill({ contentType: "text/css", body: styleBody }), + ); + routes.push(`**${marker}style.css`); + }; + + const cleanup = async () => { + for (const pattern of routes.splice(0)) await browserContext.unroute(pattern).catch(() => {}); + for (const node of injected.splice(0)) { + await node.evaluate((element) => element.remove()).catch(() => {}); + } + // A rejected addScriptTag/addStyleTag can leave its half-created node behind; + // sweep only this session's marker so foreign resources are never touched. + await context + .evaluate((needle) => { + for (const node of Array.from(document.querySelectorAll("script[src],link[href]"))) { + const reference = node.getAttribute("src") ?? node.getAttribute("href") ?? ""; + if (reference.includes(needle)) node.remove(); + } + }, marker) + .catch(() => {}); + if (acquired) { + acquired = false; + const remaining = (realms.get(page) ?? 1) - 1; + if (remaining <= 0) { + realms.delete(page); + await context + .evaluate((symbol) => { + delete globalThis[Symbol.for(symbol)]; + }, PAYLOAD_SYMBOL) + .catch(() => {}); + } else { + realms.set(page, remaining); + } + } + }; + + try { + await installRoutes(); + // Stylesheet first so the first painted frame is styled, then the payload. + injected.push(await context.addStyleTag({ url: stylePath })); + injected.push(await context.addScriptTag({ url: payloadPath })); + await context.waitForFunction( + (symbol) => typeof globalThis[Symbol.for(symbol)]?.runtime === "object", + PAYLOAD_SYMBOL, + { timeout: 5000 }, + ); + acquired = true; + realms.set(page, (realms.get(page) ?? 0) + 1); + } catch (error) { + await cleanup(); + throw new StetPlaywrightError( + "LOAD_FAILED", + `stet playwright: package-local assets failed to load: ${error?.message ?? error}`, + { cause: error }, + ); + } + + const ensureLive = () => { + if (disposed) throw new StetPlaywrightError("SESSION_DISPOSED", "stet playwright: session disposed"); + if (invalidated) throw new StetPlaywrightError("NAVIGATION", "stet playwright: session invalidated by navigation"); + }; + + const invoke = async (handleId, method) => { + ensureLive(); + try { + return await context.evaluate( + ({ symbol, id: handle, method: name }) => { + const payload = globalThis[Symbol.for(symbol)]; + const target = payload?.handles?.get(handle); + if (!target) return { __stetMissingHandle: true }; + return target[name](); + }, + { symbol: PAYLOAD_SYMBOL, id: handleId, method }, + ); + } catch (error) { + throw classify(error, session); + } + }; + + const session = { + invalidate() { + invalidated = true; + }, + async circle(locator, options = {}) { + ensureLive(); + let handleId; + try { + const count = await locator.count(); + if (count !== 1) + throw new StetPlaywrightError( + count === 0 ? "TARGET_MISSING" : "TARGET_AMBIGUOUS", + `stet playwright: locator matched ${count} elements`, + ); + handleId = await locator.evaluate( + (element, { symbol, opts }) => { + const payload = globalThis[Symbol.for(symbol)]; + const handle = payload.runtime.circle(element, opts); + const id = payload.next++; + payload.handles.set(id, handle); + return id; + }, + { symbol: PAYLOAD_SYMBOL, opts: options }, + ); + } catch (error) { + if (error instanceof StetPlaywrightError) throw error; + throw classify(error, session); + } + attached.push(handleId); + return { + show: () => invoke(handleId, "show"), + hide: () => invoke(handleId, "hide"), + replay: () => invoke(handleId, "replay"), + refresh: () => invoke(handleId, "refresh"), + resketch: () => invoke(handleId, "resketch"), + destroy: () => invoke(handleId, "destroy"), + }; + }, + async dispose() { + if (disposed) return; + disposed = true; + await context + .evaluate( + ({ symbol, ids }) => { + const payload = globalThis[Symbol.for(symbol)]; + if (!payload) return; + for (const id of ids) { + payload.handles.get(id)?.destroy(); + payload.handles.delete(id); + } + }, + { symbol: PAYLOAD_SYMBOL, ids: attached.splice(0) }, + ) + .catch(() => {}); + await cleanup(); + }, + }; + return session; +} diff --git a/tests/pw02/fixtures/authored.js b/tests/pw02/fixtures/authored.js new file mode 100644 index 0000000..0f4c4aa --- /dev/null +++ b/tests/pw02/fixtures/authored.js @@ -0,0 +1,13 @@ +// Source-authored Stet in the packed consumer. It imports the ESM browser core +// from inside the installed tarball (`/__pkg__/` is routed to the installed +// package by the spec), exactly as application code would after `npm install`. +import { circle } from "/__pkg__/dist/index.js"; + +const target = document.getElementById("authored"); +const handle = circle(target, { seed: 42, stroke: "#123456" }); +const overlay = document.querySelector(".stet-overlay--circle"); +const path = overlay?.querySelector("path") ?? null; +overlay?.setAttribute("data-authored", "1"); + +// Captured references the helper must never disturb. +globalThis.__authored = { handle, overlay, path, d: path?.getAttribute("d") ?? null }; diff --git a/tests/pw02/fixtures/base.css b/tests/pw02/fixtures/base.css new file mode 100644 index 0000000..65071a3 --- /dev/null +++ b/tests/pw02/fixtures/base.css @@ -0,0 +1,42 @@ +/* Neutral page styles for the PW-02 packed-consumer fixtures. Not Stet. */ +html, +body { + margin: 0; + padding: 0; + min-height: 600px; +} + +.target { + position: absolute; + left: 40px; + width: 160px; + height: 40px; + background: #dde5dd; +} + +#target, +#target-b { + top: 60px; +} + +#authored { + top: 160px; +} + +#helper-a { + top: 260px; +} + +#helper-b { + top: 360px; +} + +.foreign-annotation { + position: absolute; + top: 460px; + left: 40px; + width: 160px; + height: 40px; + outline: 5px solid rgb(10, 20, 30); + background: rgb(1, 2, 3); +} diff --git a/tests/pw02/fixtures/coexist.html b/tests/pw02/fixtures/coexist.html new file mode 100644 index 0000000..2c83d3b --- /dev/null +++ b/tests/pw02/fixtures/coexist.html @@ -0,0 +1,22 @@ + + + + + + + + + + + PW-02 packed coexist + + + + + +
+ + diff --git a/tests/pw02/fixtures/foreign.css b/tests/pw02/fixtures/foreign.css new file mode 100644 index 0000000..37572f7 --- /dev/null +++ b/tests/pw02/fixtures/foreign.css @@ -0,0 +1,4 @@ +/* Foreign annotations keep their own resource and must survive helper cleanup. */ +.foreign-annotation { + outline-color: rgb(10, 20, 30); +} diff --git a/tests/pw02/fixtures/strict-b.html b/tests/pw02/fixtures/strict-b.html new file mode 100644 index 0000000..bfcd693 --- /dev/null +++ b/tests/pw02/fixtures/strict-b.html @@ -0,0 +1,15 @@ + + + + + + + PW-02 packed strict (navigation target) + + + + + diff --git a/tests/pw02/fixtures/strict.html b/tests/pw02/fixtures/strict.html new file mode 100644 index 0000000..c9b06a2 --- /dev/null +++ b/tests/pw02/fixtures/strict.html @@ -0,0 +1,16 @@ + + + + + + + PW-02 packed strict + + + + foreign annotation + + diff --git a/tests/pw02/global-setup.ts b/tests/pw02/global-setup.ts new file mode 100644 index 0000000..591dbd7 --- /dev/null +++ b/tests/pw02/global-setup.ts @@ -0,0 +1,21 @@ +import { execFileSync } from "node:child_process"; +import { rmSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +// PW-02 investigation only. Not wired into package scripts. +// - rebuild the package ESM dist the packed consumer imports +// - bundle the proposed IIFE payload into the gitignored dist/playwright/ +// - pack, install offline into a fresh temp consumer, and record graph evidence +export default function globalSetup(): void { + const root = fileURLToPath(new URL("../../", import.meta.url)); + rmSync(`${root}test-results/pw02`, { recursive: true, force: true }); + execFileSync(process.execPath, [`${root}node_modules/typescript/bin/tsc`, "-p", `${root}tsconfig.json`], { + cwd: root, + stdio: "inherit", + }); + execFileSync(process.execPath, [`${root}tests/pw02/build-payload.mjs`], { cwd: root, stdio: "inherit" }); + execFileSync(process.execPath, [`${root}tests/pw02/prepare-consumer.mjs`], { + cwd: root, + stdio: "inherit", + }); +} diff --git a/tests/pw02/packed.spec.ts b/tests/pw02/packed.spec.ts new file mode 100644 index 0000000..3f4a582 --- /dev/null +++ b/tests/pw02/packed.spec.ts @@ -0,0 +1,202 @@ +import { expect, test, type Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +interface Env { + entryUrl: string; + packageRoot: string; + payloadFile: string; + styleFile: string; + tarball: string; + installMethod: string; + packedFileCount: number; + packedPaths: string[]; + payloadGraph: { inputs: string[] }; + coreGraph: { inputs: string[] }; +} + +const ENV: Env = JSON.parse( + readFileSync(fileURLToPath(new URL("../../test-results/pw02/env.json", import.meta.url)), "utf8"), +); + +const STRICT = "/tests/pw02/fixtures/strict.html"; +const STRICT_B = "/tests/pw02/fixtures/strict-b.html"; +const COEXIST = "/tests/pw02/fixtures/coexist.html"; +const CONTENT_TYPE: Record = { + ".css": "text/css", + ".js": "text/javascript", + ".json": "application/json", +}; + +async function entry() { + return import(ENV.entryUrl); +} + +/** Serve the installed tarball's files as same-origin URLs for authored code. */ +async function routePackedPackage(page: Page) { + await page.context().route("**/__pkg__/**", (route) => { + const pathname = decodeURIComponent(new URL(route.request().url()).pathname); + const relative = pathname.replace(/^\/__pkg__\//, ""); + const file = join(ENV.packageRoot, relative); + const extension = file.slice(file.lastIndexOf(".")); + route.fulfill({ contentType: CONTENT_TYPE[extension] ?? "application/octet-stream", body: readFileSync(file) }); + }); +} + +const state = (page: Page) => + page.evaluate(() => ({ + payload: Boolean((globalThis as Record)[Symbol.for("funsaized.stet.playwright.payload")]), + scripts: document.querySelectorAll('script[src*="__stet__"]').length, + links: document.querySelectorAll('link[href*="__stet__"]').length, + overlays: document.querySelectorAll(".stet-overlay").length, + })); + +test("packed IIFE + CSS render a real reveal from package-local assets", async ({ page }) => { + await page.goto(STRICT); + const { createStet } = await entry(); + const session = await createStet(page); + const handle = await session.circle(page.locator("#target"), { + seed: 5, + animate: true, + animationDuration: 300, + }); + await expect(page.locator(".stet-overlay--circle")).toHaveCount(1); + + const style = await page.locator(".stet-overlay--circle path").evaluate((element) => { + const path = getComputedStyle(element); + const overlay = getComputedStyle(element.closest(".stet-overlay") as Element); + return { stroke: path.stroke, pointerEvents: overlay.pointerEvents }; + }); + // Values below can only come from the packed style.css. + expect(style.stroke).toBe("rgb(201, 42, 42)"); + expect(style.pointerEvents).toBe("none"); + + // No CDN: every package asset request stayed on the page origin. + const origins = await page.evaluate(() => + performance + .getEntriesByType("resource") + .filter((entry) => entry.name.includes("__stet__")) + .map((entry) => new URL(entry.name).origin), + ); + expect(origins.length).toBeGreaterThan(0); + expect(origins.every((origin) => origin === new URL(page.url()).origin)).toBe(true); + + const result = await handle.show(); + expect(["finished", "cancelled"]).toContain(result.status); + + await session.dispose(); + await expect(page.locator(".stet-overlay--circle")).toHaveCount(0); + expect((await state(page)).payload).toBe(false); +}); + +test("navigation during a pending reveal settles the call as NAVIGATION, not cancelled or hung", async ({ + page, +}) => { + await page.goto(STRICT); + const { createStet, StetPlaywrightError } = await entry(); + const session = await createStet(page); + const handle = await session.circle(page.locator("#target"), { + seed: 7, + animate: true, + animationDuration: 60_000, + }); + + let resolved: unknown; + let rejected: { code?: string; name?: string } | undefined; + const pending = handle.show().then( + (value: unknown) => { + resolved = value; + }, + (error: { code?: string; name?: string }) => { + rejected = error; + }, + ); + + await page.goto(STRICT_B); + + const outcome = await Promise.race([ + pending.then(() => "settled"), + new Promise((resolve) => setTimeout(() => resolve("hung"), 5000)), + ]); + expect(outcome).toBe("settled"); + expect(resolved).toBeUndefined(); + expect(rejected).toBeInstanceOf(StetPlaywrightError); + expect(rejected?.code).toBe("NAVIGATION"); + + // No automatic reinjection into the new document. + const after = await state(page); + expect(after).toEqual({ payload: false, scripts: 0, links: 0, overlays: 0 }); + + // The invalidated session rejects later primitives without touching the page. + await expect(session.circle(page.locator("#target-b"))).rejects.toMatchObject({ + code: "NAVIGATION", + }); +}); + +test("dispose removes only its own resources; authored and a second session survive", async ({ + page, +}) => { + await routePackedPackage(page); + await page.goto(COEXIST); + await page.waitForFunction(() => Boolean((globalThis as Record).__authored)); + + const { createStet } = await entry(); + const sessionA = await createStet(page); + const sessionB = await createStet(page); + await sessionA.circle(page.locator("#helper-a"), { seed: 1 }); + await sessionB.circle(page.locator("#helper-b"), { seed: 2 }); + await expect(page.locator(".stet-overlay--circle")).toHaveCount(3); + + const snapshot = () => + page.evaluate(() => { + const authored = (globalThis as Record).__authored as { + overlay: Element; + path: Element; + }; + const foreign = getComputedStyle(document.getElementById("foreign") as Element); + return { + authoredConnected: authored.overlay.isConnected, + authoredD: authored.path.getAttribute("d") as string | null, + foreignOutline: foreign.outlineColor, + foreignBackground: foreign.backgroundColor, + }; + }); + const before = await snapshot(); + expect(before.authoredConnected).toBe(true); + + await sessionA.dispose(); + // Only A's overlay and A's injected tag pair are gone. + await expect(page.locator(".stet-overlay--circle")).toHaveCount(2); + expect((await state(page)).links).toBe(1); + expect((await state(page)).scripts).toBe(1); + + const afterA = await snapshot(); + expect(afterA).toEqual(before); + + // B still owns working resources. + await expect(page.locator(".stet-overlay--circle")).toHaveCount(2); + const extra = await sessionB.circle(page.locator("#helper-a"), { seed: 9 }); + await extra.destroy(); + await expect(page.locator(".stet-overlay--circle")).toHaveCount(2); + + await sessionB.dispose(); + await expect(page.locator(".stet-overlay--circle")).toHaveCount(1); + expect(await snapshot()).toEqual(before); +}); + +for (const abort of ["payload", "style"] as const) { + test(`load failure (${abort}) rolls back and reports LOAD_FAILED`, async ({ page }) => { + await page.goto(STRICT); + const { createStet, StetPlaywrightError } = await entry(); + let failure: { code?: string } | undefined; + try { + await createStet(page, { abort }); + } catch (error) { + failure = error as { code?: string }; + } + expect(failure).toBeInstanceOf(StetPlaywrightError); + expect(failure?.code).toBe("LOAD_FAILED"); + expect(await state(page)).toEqual({ payload: false, scripts: 0, links: 0, overlays: 0 }); + }); +} diff --git a/tests/pw02/prepare-consumer.mjs b/tests/pw02/prepare-consumer.mjs new file mode 100644 index 0000000..97b5115 --- /dev/null +++ b/tests/pw02/prepare-consumer.mjs @@ -0,0 +1,158 @@ +// PW-02 packed-consumer check. Packs the provider, installs the tarball into a +// fresh temp consumer without network (offline npm install, tar extract fallback), +// proves package-local discovery of the prebuilt IIFE + CSS, and records the +// browser-core module graph. Run by tests/pw02/global-setup.ts. +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); +const work = join(root, "test-results/pw02"); +const consumer = join(work, "consumer"); +const cache = join(work, "npm-cache"); +const run = (cmd, args, options = {}) => execFileSync(cmd, args, { encoding: "utf8", ...options }); +const sha256 = (file) => createHash("sha256").update(readFileSync(file)).digest("hex"); + +rmSync(consumer, { recursive: true, force: true }); +mkdirSync(consumer, { recursive: true }); + +const [pack] = JSON.parse( + run("npm", ["pack", "--ignore-scripts", "--json", "--cache", cache, "--pack-destination", work], { + cwd: root, + }), +); +const packedPaths = new Set(pack.files.map((file) => file.path)); +for (const required of [ + "style.css", + "package.json", + "dist/index.js", + "dist/playwright/payload.js", + "dist/playwright/entry.mjs", +]) { + assert(packedPaths.has(required), `Packed package is missing ${required}`); +} + +const providerPackageJson = JSON.parse( + run("tar", ["-xOf", join(work, pack.filename), "package/package.json"]), +); +assert.equal( + Object.keys(providerPackageJson.exports).includes("./playwright"), + false, + "PW-02 experiment must not expose a public /playwright export", +); + +writeFileSync( + join(consumer, "package.json"), + JSON.stringify({ name: "stet-pw02-consumer", private: true, type: "module" }), +); + +const tarball = join(work, pack.filename); +const installDir = join(consumer, "node_modules/@funsaized/stet"); +let installMethod = "npm-install-offline"; +try { + run( + "npm", + [ + "install", + tarball, + "--ignore-scripts", + "--offline", + "--no-audit", + "--no-fund", + "--cache", + cache, + ], + { cwd: consumer }, + ); + assert(existsSync(join(installDir, "package.json")), "offline install did not place the package"); +} catch { + // Offline npm can still fail for reasons unrelated to the package. Extracting + // the same tarball proves consumption without any network at all. + installMethod = "tar-extract"; + rmSync(installDir, { recursive: true, force: true }); + mkdirSync(installDir, { recursive: true }); + run("tar", ["-xzf", tarball, "-C", installDir, "--strip-components=1"]); +} + +const payloadFile = join(installDir, "dist/playwright/payload.js"); +const entryFile = join(installDir, "dist/playwright/entry.mjs"); +const styleFile = join(installDir, "style.css"); +for (const file of [payloadFile, entryFile, styleFile]) + assert(existsSync(file), `Installed package is missing ${file}`); + +// Package-local discovery: the entry finds both assets relative to its own URL. +// This is exactly how the future entry would resolve them after `npm install`. +const entryUrl = pathToFileURL(entryFile).href; +assert(entryUrl.includes("/node_modules/@funsaized/stet/dist/playwright/entry.mjs")); + +// Browser-core isolation: bundling the public entry from the consumer graph must +// pull only browser modules, with no Node builtins and no Playwright code. +const core = await build({ + stdin: { + contents: 'import * as stet from "@funsaized/stet"; export default stet;', + resolveDir: consumer, + }, + bundle: true, + write: false, + platform: "browser", + format: "esm", + metafile: true, + logLevel: "silent", +}); +const coreInputs = Object.keys(core.metafile.inputs).map((path) => path.replaceAll("\\", "/")); +assert( + coreInputs.every( + (path) => path === "" || path.includes("node_modules/@funsaized/stet/dist/"), + ), + `browser-core graph escaped the package: ${coreInputs.join(", ")}`, +); +assert( + coreInputs.every((path) => !/node:|@playwright/.test(path)), + `browser-core graph leaked Node/Playwright code: ${coreInputs.filter((p) => /node:|@playwright/.test(p)).join(", ")}`, +); +assert( + Object.values(core.metafile.outputs).every((output) => output.imports.length === 0), + "browser-core bundle kept external imports", +); +const payloadGraph = JSON.parse(readFileSync(join(work, "payload-graph.json"), "utf8")); + +const env = { + node: process.version, + npm: run("npm", ["--version"]), + platform: process.platform, + tarball: pack.filename, + tarballBytes: pack.size, + unpackedBytes: pack.unpackedSize, + packedFileCount: pack.files.length, + packedPaths: [...packedPaths].sort(), + installMethod, + packageRoot: installDir, + entryUrl, + payloadFile, + styleFile, + payloadSha256: sha256(payloadFile), + styleSha256: sha256(styleFile), + payloadGraph, + coreGraph: { inputs: coreInputs.sort() }, +}; +writeFileSync(join(work, "env.json"), JSON.stringify(env, null, 2)); +console.log( + JSON.stringify( + { + ok: true, + installMethod, + tarball: pack.filename, + bytes: pack.size, + packedFiles: pack.files.length, + payloadInputs: payloadGraph.inputs, + coreInputs: coreInputs.sort(), + styleBytes: statSync(styleFile).size, + }, + null, + 2, + ), +); diff --git a/tests/pw04/fixtures/authored.js b/tests/pw04/fixtures/authored.js new file mode 100644 index 0000000..707e82a --- /dev/null +++ b/tests/pw04/fixtures/authored.js @@ -0,0 +1,4 @@ +import { circle } from "/dist/index.js"; + +const handle = circle(document.querySelector("#authored"), { seed: 77 }); +globalThis.__authored = { handle, overlay: document.querySelector(".stet-overlay") }; diff --git a/tests/pw04/fixtures/base.css b/tests/pw04/fixtures/base.css new file mode 100644 index 0000000..8bb0a85 --- /dev/null +++ b/tests/pw04/fixtures/base.css @@ -0,0 +1,4 @@ +body { min-height: 700px; } +.target { display: inline-block; margin: 24px; padding: 12px; } +.foreign { outline: 3px solid rgb(1, 2, 3); } +iframe { width: 800px; height: 300px; } diff --git a/tests/pw04/fixtures/frame.html b/tests/pw04/fixtures/frame.html new file mode 100644 index 0000000..8e5630b --- /dev/null +++ b/tests/pw04/fixtures/frame.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/pw04/fixtures/main.html b/tests/pw04/fixtures/main.html new file mode 100644 index 0000000..4f68414 --- /dev/null +++ b/tests/pw04/fixtures/main.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + foreign + + + + diff --git a/tests/pw04/global-setup.ts b/tests/pw04/global-setup.ts new file mode 100644 index 0000000..1eede66 --- /dev/null +++ b/tests/pw04/global-setup.ts @@ -0,0 +1,7 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +export default function globalSetup(): void { + const root = fileURLToPath(new URL("../../", import.meta.url)); + execFileSync("npm", ["run", "build", "--silent"], { cwd: root, stdio: "inherit" }); +} diff --git a/tests/pw04/sessions.spec.ts b/tests/pw04/sessions.spec.ts new file mode 100644 index 0000000..eedc1a5 --- /dev/null +++ b/tests/pw04/sessions.spec.ts @@ -0,0 +1,129 @@ +import { expect, test, type Frame, type Locator } from "@playwright/test"; +import { createStet, StetPlaywrightError } from "../../dist/playwright/entry.js"; + +const MAIN = "/tests/pw04/fixtures/main.html"; + +async function sameFrame(page: import("@playwright/test").Page): Promise { + const frame = page.frames().find((candidate) => candidate.url().includes("/frame.html") && candidate.url().includes("127.0.0.1")); + expect(frame).toBeTruthy(); + return frame!; +} + +async function expectCode(promise: Promise, code: string, role?: string) { + await expect(promise).rejects.toMatchObject({ name: "StetPlaywrightError", code, ...(role ? { role } : {}) }); +} + +for (const target of ["page", "frame"] as const) { + test(`${target}: all public primitive methods attach to the bound document`, async ({ page }) => { + await page.goto(MAIN); + await page.waitForFunction(() => Boolean((globalThis as Record).__authored)); + const context = target === "page" ? page : await sameFrame(page); + const session = await createStet(context); + const one = context.locator("#one"); + const two = context.locator("#two"); + const handles = [ + await session.circle(one), + await session.underline(one), + await session.highlight(one), + await session.arrow(one, two), + await session.sticky(one, { text: "note" }), + await session.mark(one, "right"), + ]; + expect(handles).toHaveLength(6); + await expect(context.locator(".stet-overlay")).toHaveCount(target === "page" ? 7 : 6); + await session.dispose(); + await expect(context.locator(".stet-overlay")).toHaveCount(target === "page" ? 1 : 0); + await expect(page.locator(".stet-overlay")).toHaveCount(1); + }); +} + +test("exact-one snapshot distinguishes missing, ambiguous, detached, and wrong document", async ({ page }) => { + await page.goto(MAIN); + const session = await createStet(page); + await expectCode(session.circle(page.locator("#missing")), "TARGET_MISSING", "target"); + await expectCode(session.circle(page.locator(".many")), "TARGET_AMBIGUOUS", "target"); + const frame = await sameFrame(page); + await expectCode(session.circle(frame.locator("#one")), "WRONG_DOCUMENT", "target"); + + const locator = page.locator("#one"); + const original = locator.elementHandles.bind(locator); + const racing = Object.create(locator) as Locator; + racing.elementHandles = async () => { + const handles = await original(); + await page.locator("#one").evaluate((element) => element.replaceWith(element.cloneNode(true))); + return handles; + }; + await expectCode(session.circle(racing), "TARGET_DETACHED", "target"); + await expect(page.locator(".stet-overlay")).toHaveCount(1); + await session.dispose(); +}); + +test("arrows resolve from then to and reject cross-document endpoints without attaching", async ({ page }) => { + await page.goto(MAIN); + const session = await createStet(page); + await expectCode(session.arrow(page.locator("#missing"), page.locator("#two")), "TARGET_MISSING", "from"); + await expectCode(session.arrow(page.locator("#one"), (await sameFrame(page)).locator("#two")), "WRONG_DOCUMENT", "to"); + await expect(page.locator(".stet-overlay")).toHaveCount(1); + await session.dispose(); +}); + +test("same-origin frames work; cross-origin and detached frames are rejected without mutation", async ({ page }) => { + await page.goto(MAIN); + const same = await sameFrame(page); + const session = await createStet(same); + await session.circle(same.locator("#one")); + await session.dispose(); + + const blank = await page.evaluate(() => { + const iframe = document.createElement("iframe"); + iframe.id = "blank"; + document.body.append(iframe); + iframe.contentDocument!.body.innerHTML = ''; + return true; + }); + expect(blank).toBe(true); + const blankFrame = page.frames().find((frame) => frame.url() === "about:blank"); + expect(blankFrame).toBeTruthy(); + const blankSession = await createStet(blankFrame!); + await blankSession.circle(blankFrame!.locator("#blank-target")); + await blankSession.dispose(); + + const cross = page.frames().find((frame) => frame.url().includes("localhost:4184")); + expect(cross).toBeTruthy(); + await expectCode(createStet(cross!), "UNSUPPORTED_CONTEXT"); + const detached = await sameFrame(page); + await page.locator("#same").evaluate((element) => element.remove()); + await expectCode(createStet(detached), "UNSUPPORTED_CONTEXT"); + await expect(page.locator(".stet-overlay")).toHaveCount(1); +}); + +test("session cleanup leaves another session, source-authored Stet, and foreign content", async ({ page }) => { + await page.goto(MAIN); + await page.waitForFunction(() => Boolean((globalThis as Record).__authored)); + const before = await page.locator("#foreign").evaluate((element) => getComputedStyle(element).outline); + const a = await createStet(page); + const b = await createStet(page); + await a.circle(page.locator("#one")); + await b.circle(page.locator("#two")); + await expect(page.locator(".stet-overlay")).toHaveCount(3); + await a.dispose(); + await expect(page.locator(".stet-overlay")).toHaveCount(2); + await b.dispose(); + await expect(page.locator(".stet-overlay")).toHaveCount(1); + expect(await page.locator("#foreign").evaluate((element) => getComputedStyle(element).outline)).toBe(before); +}); + +test("failed runtime attachment rolls back without adopting a handle", async ({ page }) => { + await page.goto(MAIN); + const session = await createStet(page); + await expectCode(session.circle(page.locator("#one"), { animationDuration: -1 }), "INVALID_OPTIONS"); + await expect(page.locator(".stet-overlay")).toHaveCount(1); + await session.dispose(); +}); + +test("unsupported objects are rejected without page mutation", async ({ page }) => { + await page.goto(MAIN); + await expectCode(createStet(page.locator("#one") as never), "UNSUPPORTED_CONTEXT"); + await expect(page.locator(".stet-overlay")).toHaveCount(1); + expect(StetPlaywrightError).toBeDefined(); +}); diff --git a/tests/pw05/fixtures/authored.js b/tests/pw05/fixtures/authored.js new file mode 100644 index 0000000..a230fd2 --- /dev/null +++ b/tests/pw05/fixtures/authored.js @@ -0,0 +1,3 @@ +import { circle } from "/dist/index.js"; + +globalThis.__authored = circle(document.querySelector("#authored"), { seed: 70 }); diff --git a/tests/pw05/fixtures/frame.html b/tests/pw05/fixtures/frame.html new file mode 100644 index 0000000..867c496 --- /dev/null +++ b/tests/pw05/fixtures/frame.html @@ -0,0 +1 @@ + diff --git a/tests/pw05/fixtures/main.html b/tests/pw05/fixtures/main.html new file mode 100644 index 0000000..445f593 --- /dev/null +++ b/tests/pw05/fixtures/main.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/pw05/fixtures/next.html b/tests/pw05/fixtures/next.html new file mode 100644 index 0000000..46ecbcf --- /dev/null +++ b/tests/pw05/fixtures/next.html @@ -0,0 +1 @@ + diff --git a/tests/pw05/global-setup.ts b/tests/pw05/global-setup.ts new file mode 100644 index 0000000..a52be35 --- /dev/null +++ b/tests/pw05/global-setup.ts @@ -0,0 +1,9 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +export default function globalSetup(): void { + execFileSync("npm", ["run", "build", "--silent"], { + cwd: fileURLToPath(new URL("../../", import.meta.url)), + stdio: "inherit", + }); +} diff --git a/tests/pw05/lifecycle.spec.ts b/tests/pw05/lifecycle.spec.ts new file mode 100644 index 0000000..f99ea1b --- /dev/null +++ b/tests/pw05/lifecycle.spec.ts @@ -0,0 +1,124 @@ +import { expect, test, type Page } from "@playwright/test"; +import { createStet } from "../../dist/playwright/entry.js"; + +const MAIN = "/tests/pw05/fixtures/main.html"; +const NEXT = "/tests/pw05/fixtures/next.html"; + +async function loaded(page: Page) { + await page.goto(MAIN); + await page.waitForFunction(() => Boolean((globalThis as Record).__authored)); +} + +async function settles(promise: Promise): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error("remote call hung")), 5000)), + ]); +} + +test("remote handles preserve join, interruption, replay, and destroyed behavior", async ({ page }) => { + await loaded(page); + const session = await createStet(page); + const handle = await session.circle(page.locator("#one"), { animate: true, animationDuration: 60_000 }); + const first = handle.show(); + expect(handle.show()).toBe(first); + await handle.hide(); + await expect(settles(first)).resolves.toEqual({ status: "cancelled" }); + + const replay = handle.replay(); + expect(handle.show()).toBe(replay); + await handle.destroy(); + await expect(settles(replay)).resolves.toEqual({ status: "cancelled" }); + const afterDestroyA = handle.show(); + const afterDestroyB = handle.show(); + expect(afterDestroyA).not.toBe(afterDestroyB); + await expect(afterDestroyA).resolves.toEqual({ status: "cancelled" }); + await expect(handle.replay()).resolves.toEqual({ status: "cancelled" }); + await handle.hide(); + await handle.refresh(); + await handle.resketch(3); + await handle.destroy(); + await session.dispose(); +}); + +test("dispose interrupts reveals, is idempotent, and leaves handles destroyed", async ({ page }) => { + await loaded(page); + const session = await createStet(page); + const handle = await session.circle(page.locator("#one"), { animate: true, animationDuration: 60_000 }); + const pending = handle.show(); + await session.dispose(); + await expect(settles(pending)).resolves.toEqual({ status: "cancelled" }); + await expect(handle.show()).resolves.toEqual({ status: "cancelled" }); + await handle.destroy(); + await session.dispose(); + await expect(session.circle(page.locator("#two"))).rejects.toMatchObject({ code: "SESSION_DISPOSED" }); + await expect(page.locator(".stet-overlay")).toHaveCount(1); +}); + +test("navigation rejects a pending call, invalidates permanently, and never reinjects", async ({ page }) => { + await loaded(page); + const session = await createStet(page); + const handle = await session.circle(page.locator("#one"), { animate: true, animationDuration: 60_000 }); + const pending = handle.show(); + await page.goto(NEXT); + await expect(settles(pending)).rejects.toMatchObject({ code: "NAVIGATION" }); + await expect(handle.refresh()).rejects.toMatchObject({ code: "NAVIGATION" }); + await expect(session.circle(page.locator("#next"))).rejects.toMatchObject({ code: "NAVIGATION" }); + await session.dispose(); + await expect(page.locator(".stet-overlay")).toHaveCount(0); + expect(await page.evaluate(() => document.querySelectorAll('script[src*="__stet__"],link[href*="__stet__"]').length)).toBe(0); +}); + +test("frame detachment rejects its pending call and invalidates the frame session", async ({ page }) => { + await loaded(page); + const frame = page.frames().find((candidate) => candidate.url().includes("/frame.html"))!; + const session = await createStet(frame); + const handle = await session.circle(frame.locator("#one"), { animate: true, animationDuration: 60_000 }); + const pending = handle.show(); + await page.locator("#frame").evaluate((element) => element.remove()); + await expect(settles(pending)).rejects.toMatchObject({ code: "FRAME_DETACHED" }); + await expect(session.circle(frame.locator("#one"))).rejects.toMatchObject({ code: "FRAME_DETACHED" }); + await session.dispose(); +}); + +test("frame navigation rejects its pending call as NAVIGATION, not detachment", async ({ page }) => { + await loaded(page); + const frame = page.frames().find((candidate) => candidate.url().includes("/frame.html"))!; + const session = await createStet(frame); + const handle = await session.circle(frame.locator("#one"), { animate: true, animationDuration: 60_000 }); + const pending = handle.show(); + await page.locator("#frame").evaluate((element) => element.setAttribute("src", "/tests/pw05/fixtures/next.html")); + await expect(settles(pending)).rejects.toMatchObject({ code: "NAVIGATION" }); + await expect(session.circle(frame.locator("#next"))).rejects.toMatchObject({ code: "NAVIGATION" }); + await session.dispose(); +}); + +test("page closure rejects a pending call as PAGE_CLOSED", async ({ page }) => { + await loaded(page); + const session = await createStet(page); + const handle = await session.circle(page.locator("#one"), { animate: true, animationDuration: 60_000 }); + const pending = handle.show(); + await page.close(); + await expect(settles(pending)).rejects.toMatchObject({ code: "PAGE_CLOSED" }); + await session.dispose(); +}); + +test("cleanup is session-owned and unexpected browser failures stay visible", async ({ page }) => { + await loaded(page); + const a = await createStet(page); + const b = await createStet(page); + const handleA = await a.circle(page.locator("#one")); + const handleB = await b.circle(page.locator("#two")); + await expect(page.locator(".stet-overlay")).toHaveCount(3); + await a.dispose(); + await expect(page.locator(".stet-overlay")).toHaveCount(2); + await page.evaluate(() => { + const payload = (globalThis as Record }>)[Symbol.for("funsaized.stet.playwright.payload")]; + const remote = payload.handles.values().next().value; + if (remote) remote.refresh = () => { throw new Error("fixture browser failure"); }; + }); + await expect(handleB.refresh()).rejects.toMatchObject({ code: "BROWSER_OPERATION" }); + await expect(handleA.show()).resolves.toEqual({ status: "cancelled" }); + await b.dispose(); + await expect(page.locator(".stet-overlay")).toHaveCount(1); +}); diff --git a/tests/pw06/fixtures/blocked.html b/tests/pw06/fixtures/blocked.html new file mode 100644 index 0000000..9f43203 --- /dev/null +++ b/tests/pw06/fixtures/blocked.html @@ -0,0 +1,2 @@ + + diff --git a/tests/pw06/fixtures/frame.html b/tests/pw06/fixtures/frame.html new file mode 100644 index 0000000..9f2a748 --- /dev/null +++ b/tests/pw06/fixtures/frame.html @@ -0,0 +1,2 @@ + + diff --git a/tests/pw06/fixtures/next.html b/tests/pw06/fixtures/next.html new file mode 100644 index 0000000..26378fd --- /dev/null +++ b/tests/pw06/fixtures/next.html @@ -0,0 +1 @@ + diff --git a/tests/pw06/fixtures/page.css b/tests/pw06/fixtures/page.css new file mode 100644 index 0000000..520171f --- /dev/null +++ b/tests/pw06/fixtures/page.css @@ -0,0 +1,2 @@ +button { display: inline-block; margin: 40px; padding: 12px; } +iframe { width: 700px; height: 250px; } diff --git a/tests/pw06/fixtures/strict.html b/tests/pw06/fixtures/strict.html new file mode 100644 index 0000000..ee3cd69 --- /dev/null +++ b/tests/pw06/fixtures/strict.html @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/tests/pw06/global-setup.ts b/tests/pw06/global-setup.ts new file mode 100644 index 0000000..a52be35 --- /dev/null +++ b/tests/pw06/global-setup.ts @@ -0,0 +1,9 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +export default function globalSetup(): void { + execFileSync("npm", ["run", "build", "--silent"], { + cwd: fileURLToPath(new URL("../../", import.meta.url)), + stdio: "inherit", + }); +} diff --git a/tests/pw06/matrix.spec.ts b/tests/pw06/matrix.spec.ts new file mode 100644 index 0000000..97a54ae --- /dev/null +++ b/tests/pw06/matrix.spec.ts @@ -0,0 +1,86 @@ +import { expect, test, type Frame } from "@playwright/test"; +import { createStet } from "../../dist/playwright/entry.js"; + +const STRICT = "/tests/pw06/fixtures/strict.html"; + +const sameFrame = (frames: Frame[]) => + frames.find((frame) => frame.url().includes("127.0.0.1:4186/tests/pw06/fixtures/frame.html"))!; + +const hasPolicyProbe = (context: import("@playwright/test").Page | Frame) => + context.evaluate(() => + Object.getOwnPropertySymbols(globalThis).some((symbol) => + symbol.description?.startsWith("funsaized.stet.playwright.policy."), + ), + ); + +test("restrictive self CSP supports Page and same-origin Frame with real CSS", async ({ page }) => { + await page.goto(STRICT); + for (const context of [page, sameFrame(page.frames())]) { + const session = await createStet(context); + expect(await hasPolicyProbe(context)).toBe(false); + await session.circle(context.locator("#one"), { seed: 6 }); + const style = await context.locator(".stet-overlay path").evaluate((path) => getComputedStyle(path).stroke); + expect(style).toBe("rgb(201, 42, 42)"); + await session.dispose(); + await expect(context.locator(".stet-overlay")).toHaveCount(0); + } +}); + +test("blocking CSP reports CSP_BLOCKED with rollback and no bypass", async ({ page }) => { + await page.goto("/tests/pw06/fixtures/blocked.html"); + await expect(createStet(page)).rejects.toMatchObject({ + code: "CSP_BLOCKED", + message: expect.stringContaining("allow same-origin script-src and style-src"), + }); + expect( + await page.evaluate(() => ({ + overlays: document.querySelectorAll(".stet-overlay").length, + resources: document.querySelectorAll('script[src*="__stet__"],link[href*="__stet__"]').length, + payload: Boolean((globalThis as Record)[Symbol.for("funsaized.stet.playwright.payload")]), + })), + ).toEqual({ overlays: 0, resources: 0, payload: false }); + expect(await hasPolicyProbe(page)).toBe(false); +}); + +test("non-policy loading failures report LOAD_FAILED and roll back", async ({ page }) => { + await page.goto(STRICT); + const frame = page.mainFrame(); + const original = frame.addStyleTag.bind(frame); + frame.addStyleTag = async () => { + throw new Error("fixture asset failure"); + }; + await expect(createStet(page)).rejects.toMatchObject({ + code: "LOAD_FAILED", + message: expect.stringContaining("package-local assets failed to load"), + }); + frame.addStyleTag = original; + expect(await hasPolicyProbe(page)).toBe(false); + expect(await page.locator('.stet-overlay, script[src*="__stet__"], link[href*="__stet__"]').count()).toBe(0); +}); + +test("unsupported contexts and cross-document arrows reject without mutation", async ({ page }) => { + await page.goto(STRICT); + const cross = page.frames().find((frame) => frame.url().includes("localhost:4186"))!; + await expect(createStet(cross)).rejects.toMatchObject({ code: "UNSUPPORTED_CONTEXT" }); + const opaque = await (await page.locator("#opaque").elementHandle())!.contentFrame(); + await expect(createStet(opaque!)).rejects.toMatchObject({ code: "UNSUPPORTED_CONTEXT" }); + await expect(createStet(page.locator("#one") as never)).rejects.toMatchObject({ code: "UNSUPPORTED_CONTEXT" }); + const session = await createStet(page); + await expect(session.arrow(page.locator("#one"), sameFrame(page.frames()).locator("#two"))).rejects.toMatchObject({ + code: "WRONG_DOCUMENT", + primitive: "arrow", + role: "to", + }); + await expect(page.locator(".stet-overlay")).toHaveCount(0); + await session.dispose(); +}); + +test("navigation invalidates without automatic reinjection", async ({ page }) => { + await page.goto(STRICT); + const session = await createStet(page); + await session.circle(page.locator("#one")); + await page.goto("/tests/pw06/fixtures/next.html"); + await expect(session.circle(page.locator("#next"))).rejects.toMatchObject({ code: "NAVIGATION" }); + expect(await page.locator('.stet-overlay, script[src*="__stet__"], link[href*="__stet__"]').count()).toBe(0); + await session.dispose(); +}); diff --git a/tests/pw07/capture.spec.ts b/tests/pw07/capture.spec.ts new file mode 100644 index 0000000..6cec40c --- /dev/null +++ b/tests/pw07/capture.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; +import { createStet } from "../../dist/playwright/entry.js"; + +test("fixed state, fonts, seed, motion, and reveal produce repeatable contextual capture", async ({ page }) => { + await page.goto("/examples/playwright/"); + await page.waitForFunction(() => document.documentElement.dataset.state === "source-ready"); + await page.evaluate(() => document.fonts.ready); + + const session = await createStet(page); + const injected = await session.circle(page.getByRole("button", { name: "Place order" }), { + seed: 29, + boil: 0.2, + animate: true, + animationDuration: 600, + description: "Action under review", + }); + await expect(injected.show()).resolves.toEqual({ status: "finished" }); + await injected.refresh(); + await expect(page.locator(".stet-overlay")).toHaveCount(2); + expect(await page.evaluate(() => document.getAnimations().length)).toBe(0); + await expect(page.getByText("Review context: checkout handoff")).toBeVisible(); + await expect(page.getByText("The source annotation marks the total.")).toBeVisible(); + + const first = await page.locator("#capture").screenshot({ + path: "test-results/pw07/capture.png", + animations: "disabled", + }); + const second = await page.locator("#capture").screenshot({ animations: "disabled" }); + expect(second.equals(first)).toBe(true); + await session.dispose(); +}); diff --git a/tests/pw07/global-setup.ts b/tests/pw07/global-setup.ts new file mode 100644 index 0000000..a52be35 --- /dev/null +++ b/tests/pw07/global-setup.ts @@ -0,0 +1,9 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +export default function globalSetup(): void { + execFileSync("npm", ["run", "build", "--silent"], { + cwd: fileURLToPath(new URL("../../", import.meta.url)), + stdio: "inherit", + }); +} diff --git a/tests/pw08/behavior.spec.ts b/tests/pw08/behavior.spec.ts new file mode 100644 index 0000000..0221e94 --- /dev/null +++ b/tests/pw08/behavior.spec.ts @@ -0,0 +1,66 @@ +import { expect, test, type Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const env: { entryUrl: string } = JSON.parse( + readFileSync(fileURLToPath(new URL("../../test-results/pw08/env.json", import.meta.url)), "utf8"), +); + +async function annotate(page: Page) { + const { createStet } = await import(env.entryUrl); + const before = await page.locator("#email").boundingBox(); + const session = await createStet(page); + await session.circle(page.locator("#email"), { seed: 4, description: "Field under review" }); + await session.underline(page.locator("#submit"), { seed: 8 }); + return { session, before }; +} + +async function verifyNativeBehavior( + page: Page, + before: { x: number; y: number; width: number; height: number } | null, +) { + expect(await page.evaluate(() => { + const nodes = (globalThis as Record).__behaviorNodes as { email: Element; submit: Element }; + return nodes.email === document.querySelector("#email") && nodes.submit === document.querySelector("#submit"); + })).toBe(true); + expect(await page.locator("#email").boundingBox()).toEqual(before); + await page.locator("#email").fill("reader@example.com"); + await expect(page.locator("#email")).toBeFocused(); + await expect(page.locator("#email")).toHaveValue("reader@example.com"); + await expect(page.locator("#email")).toHaveAccessibleDescription(/Email used for the receipt.*Field under review/); + expect(await page.locator(".stet-overlay").first().evaluate((element) => getComputedStyle(element).pointerEvents)).toBe("none"); + await page.locator("#submit").click(); + await expect(page.locator("#status")).toHaveText("Submitted reader@example.com"); +} + +test("packed helper annotations preserve independently asserted native behavior", async ({ page }) => { + await page.goto("/examples/playwright/behavior.html"); + await page.waitForFunction(() => document.documentElement.dataset.ready === "true"); + const { session, before } = await annotate(page); + try { + await verifyNativeBehavior(page, before); + } finally { + await session.dispose(); + } + await expect(page.locator(".stet-overlay")).toHaveCount(0); + await expect(page.locator("#email")).toHaveAttribute("aria-describedby", "native-help"); +}); + +test("the same annotations do not hide a broken submit, and cleanup still runs", async ({ page }) => { + await page.goto("/examples/playwright/behavior.html?broken=1"); + await page.waitForFunction(() => document.documentElement.dataset.ready === "true"); + const { session, before } = await annotate(page); + await expect(page.locator(".stet-overlay")).toHaveCount(2); + let detected: unknown; + try { + await verifyNativeBehavior(page, before); + } catch (error) { + detected = error; + } finally { + await session.dispose(); + } + expect(detected).toBeInstanceOf(Error); + await expect(page.locator("#status")).toHaveText("Broken fixture did not submit"); + await expect(page.locator(".stet-overlay")).toHaveCount(0); + await expect(page.locator("#email")).toHaveAttribute("aria-describedby", "native-help"); +}); diff --git a/tests/pw08/global-setup.ts b/tests/pw08/global-setup.ts new file mode 100644 index 0000000..c6e4221 --- /dev/null +++ b/tests/pw08/global-setup.ts @@ -0,0 +1,11 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +export default function globalSetup(): void { + const root = fileURLToPath(new URL("../../", import.meta.url)); + execFileSync("npm", ["run", "build", "--silent"], { cwd: root, stdio: "inherit" }); + execFileSync(process.execPath, [`${root}tests/pw08/prepare-consumer.mjs`], { + cwd: root, + stdio: "inherit", + }); +} diff --git a/tests/pw08/prepare-consumer.mjs b/tests/pw08/prepare-consumer.mjs new file mode 100644 index 0000000..b0c2531 --- /dev/null +++ b/tests/pw08/prepare-consumer.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); +const work = join(root, "test-results/pw08"); +const consumer = join(work, "consumer"); +const cache = join(work, "npm-cache"); +rmSync(consumer, { recursive: true, force: true }); +mkdirSync(consumer, { recursive: true }); +const run = (command, args, options = {}) => + execFileSync(command, args, { encoding: "utf8", ...options }); +const [pack] = JSON.parse( + run("npm", ["pack", "--ignore-scripts", "--json", "--cache", cache, "--pack-destination", work], { cwd: root }), +); +writeFileSync(join(consumer, "package.json"), JSON.stringify({ name: "stet-pw08-consumer", private: true, type: "module" })); +run("npm", ["install", join(work, pack.filename), "--ignore-scripts", "--offline", "--no-audit", "--no-fund", "--cache", cache], { cwd: consumer }); +const packageRoot = join(consumer, "node_modules/@funsaized/stet"); +const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")); +assert(packageJson.exports["./playwright"], "packed Playwright export is missing"); +writeFileSync( + join(work, "env.json"), + JSON.stringify({ + entryUrl: pathToFileURL(join(packageRoot, "dist/playwright/entry.js")).href, + tarball: pack.filename, + bytes: pack.size, + files: pack.files.length, + }), +); +console.log(JSON.stringify({ ok: true, tarball: pack.filename, bytes: pack.size, files: pack.files.length }, null, 2)); diff --git a/tests/react.test.ts b/tests/react.test.ts index c4b0f56..d22086b 100644 --- a/tests/react.test.ts +++ b/tests/react.test.ts @@ -1,18 +1,66 @@ -import { act, createElement, createRef, StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, expect, it } from "vitest"; -import { Circle, Arrow } from "../src/react.js"; +import { Fragment, StrictMode, act, createElement, createRef, type ReactElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { Arrow, Circle, Mark, Sticky } from "../src/react.js"; +import type { StetHandle } from "../src/index.js"; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); -afterEach(() => { document.body.textContent = ""; }); + +type Callback = (handle: StetHandle | null) => void; + +/** + * happy-dom returns zero-sized rects, which the runtime treats as culled. Pin a + * fixed onscreen box so overlay `hidden` reflects explicit visibility, not + * geometry. + */ +const RECT = new DOMRect(10, 20, 100, 30); + +beforeEach(() => { + vi.stubGlobal("innerWidth", 1024); + vi.stubGlobal("innerHeight", 768); + vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue(RECT); + vi.spyOn(Element.prototype, "getClientRects").mockReturnValue([RECT] as unknown as DOMRectList); +}); + +afterEach(() => { + document.body.textContent = ""; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function collector(): { calls: (StetHandle | null)[]; onHandle: Callback } { + const calls: (StetHandle | null)[] = []; + return { calls, onHandle: (handle) => calls.push(handle) }; +} + +function overlay(): HTMLElement { + return document.querySelector(".stet-overlay")!; +} + +function ink(): string { + return overlay().style.getPropertyValue("--stet-ink"); +} + +async function render(element: ReactElement): Promise { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(() => root.render(element)); + return root; +} it("handles Strict Mode, ref reassignment, unchanged props, and unmount", async () => { const container = document.createElement("div"); document.body.append(container); const root = createRoot(container); const ref = createRef(); - const app = (tag: string, stroke = "red") => createElement(StrictMode, null, - createElement(tag, { ref }, "Save"), createElement(Circle, { target: ref, seed: 7, stroke, description: "Important" })); + const app = (tag: string, stroke = "red") => + createElement( + StrictMode, + null, + createElement(tag, { ref }, "Save"), + createElement(Circle, { target: ref, seed: 7, stroke, description: "Important" }), + ); await act(() => root.render(app("button"))); expect(document.querySelectorAll(".stet-overlay")).toHaveLength(1); const overlay = document.querySelector(".stet-overlay"); @@ -25,7 +73,9 @@ it("handles Strict Mode, ref reassignment, unchanged props, and unmount", async expect(previousTarget.hasAttribute("aria-describedby")).toBe(false); expect(ref.current?.getAttribute("aria-describedby")).toContain("stet-description-"); await act(() => root.render(app("a", "blue"))); - expect(document.querySelector(".stet-overlay")?.style.getPropertyValue("--stet-ink")).toBe("blue"); + expect( + document.querySelector(".stet-overlay")?.style.getPropertyValue("--stet-ink"), + ).toBe("blue"); await act(() => root.unmount()); expect(document.querySelector(".stet-overlay")).toBeNull(); }); @@ -34,11 +84,16 @@ it("forwards arrow curvature, label updates, and null refs", async () => { const container = document.createElement("div"); document.body.append(container); const root = createRoot(container); - const from = createRef(), to = createRef(); - const app = (visible: boolean, label: string) => createElement("div", null, - createElement("button", { ref: from }, "From"), - visible && createElement("button", { ref: to }, "To"), - createElement(Arrow, { from, to, label, curvature: -0.3, seed: 4 })); + const from = createRef(), + to = createRef(); + const app = (visible: boolean, label: string) => + createElement( + "div", + null, + createElement("button", { ref: from }, "From"), + visible && createElement("button", { ref: to }, "To"), + createElement(Arrow, { from, to, label, curvature: -0.3, seed: 4 }), + ); await act(() => root.render(app(true, "First"))); expect(document.querySelector(".stet-label")?.textContent).toBe("First"); const target = to.current!; @@ -50,3 +105,428 @@ it("forwards arrow curvature, label updates, and null refs", async () => { expect(target.hasAttribute("aria-describedby")).toBe(false); await act(() => root.unmount()); }); + +it("delivers the handle after attachment and null on unmount", async () => { + const target = createRef(); + const { calls, onHandle } = collector(); + const root = await render( + createElement( + Fragment, + null, + createElement("button", { ref: target }, "Save"), + createElement(Circle, { target, onHandle }), + ), + ); + expect(calls).toHaveLength(1); + const handle = calls[0]!; + expect(handle).not.toBeNull(); + await act(() => root.unmount()); + expect(calls).toEqual([handle, null]); + expect(document.querySelector(".stet-overlay")).toBeNull(); +}); + +it("StrictMode destroys then nulls the old handle before re-attaching", async () => { + const target = createRef(); + const { calls, onHandle } = collector(); + const root = await render( + createElement( + StrictMode, + null, + createElement("button", { ref: target }, "Save"), + createElement(Circle, { target, onHandle }), + ), + ); + // StrictMode runs setup, cleanup, setup: handle, null, handle. + expect(calls.map((handle) => (handle ? "handle" : "null"))).toEqual(["handle", "null", "handle"]); + expect(calls[0]).not.toBe(calls[2]); + await act(() => root.unmount()); + expect(calls.map((handle) => (handle ? "handle" : "null"))).toEqual([ + "handle", + "null", + "handle", + "null", + ]); + expect(calls[3]).toBeNull(); +}); + +it("applies a changed visible prop once and preserves imperative state", async () => { + const target = createRef(); + const { calls, onHandle } = collector(); + const app = (visible?: boolean) => + createElement( + Fragment, + null, + createElement("button", { ref: target }, "Save"), + createElement(Circle, { target, seed: 1, visible, onHandle }), + ); + const root = await render(app(false)); + expect(overlay().hidden).toBe(true); + const handle = calls[0]!; + const show = vi.spyOn(handle, "show"); + const hide = vi.spyOn(handle, "hide"); + + await act(() => root.render(app(false))); + expect(show).not.toHaveBeenCalled(); + expect(hide).not.toHaveBeenCalled(); + + await act(() => root.render(app(true))); + expect(show).toHaveBeenCalledTimes(1); + expect(overlay().hidden).toBe(false); + + handle.hide(); + expect(hide).toHaveBeenCalledTimes(1); + + // An unrelated render must not overwrite the imperative hide. + await act(() => root.render(app(true))); + expect(hide).toHaveBeenCalledTimes(1); + expect(overlay().hidden).toBe(true); + + await act(() => root.render(app(false))); + expect(hide).toHaveBeenCalledTimes(2); + + // Explicit false -> omitted is a change to true. + await act(() => root.render(app(undefined))); + expect(show).toHaveBeenCalledTimes(2); + expect(overlay().hidden).toBe(false); + await act(() => root.unmount()); +}); + +it("updates non-visible options through updateHandle without remount or replay", async () => { + const target = createRef(); + const { calls, onHandle } = collector(); + const app = (stroke: string, description?: string) => + createElement( + Fragment, + null, + createElement("button", { ref: target }, "Save"), + createElement(Circle, { target, seed: 1, stroke, description, onHandle }), + ); + const root = await render(app("red")); + const handle = calls[0]!; + const element = overlay(); + const drawing = document.querySelector(".stet-svg path")!.getAttribute("d"); + + await act(() => root.render(app("blue", "Explained"))); + expect(calls).toEqual([handle]); + expect(overlay()).toBe(element); + expect(ink()).toBe("blue"); + expect(document.querySelector(".stet-description")?.textContent).toBe("Explained"); + expect(document.querySelector(".stet-svg path")!.getAttribute("d")).toBe(drawing); + expect(target.current!.getAttribute("aria-describedby")).toContain("stet-description-"); + await act(() => root.unmount()); +}); + +it("sends a complete snapshot so removed values return to defaults", async () => { + const target = createRef(); + const { calls, onHandle } = collector(); + const app = (padding?: number, description?: string) => + createElement( + Fragment, + null, + createElement("button", { ref: target }, "Save"), + createElement(Circle, { target, seed: 1, padding, description, onHandle }), + ); + const root = await render(app(20, "A")); + const handle = calls[0]!; + const wide = parseFloat(overlay().style.width); + expect(document.querySelector(".stet-description")).not.toBeNull(); + + await act(() => root.render(app())); + expect(parseFloat(overlay().style.width)).toBeLessThan(wide); + expect(document.querySelector(".stet-description")).toBeNull(); + expect(calls).toEqual([handle]); + await act(() => root.unmount()); +}); + +it("preserves an imperative hide when unrelated options change", async () => { + const target = createRef(); + const { calls, onHandle } = collector(); + const app = (stroke: string) => + createElement( + Fragment, + null, + createElement("button", { ref: target }, "Save"), + createElement(Circle, { target, seed: 1, stroke, onHandle }), + ); + const root = await render(app("red")); + const handle = calls[0]!; + handle.hide(); + expect(overlay().hidden).toBe(true); + + await act(() => root.render(app("blue"))); + expect(overlay().hidden).toBe(true); + expect(ink()).toBe("blue"); + expect(calls).toEqual([handle]); + await act(() => root.unmount()); +}); + +it("transfers a live handle to a new callback without remount, update, or replay", async () => { + const target = createRef(); + const a = collector(); + const b = collector(); + const app = (onHandle: Callback) => + createElement( + Fragment, + null, + createElement("button", { ref: target }, "Save"), + createElement(Circle, { target, seed: 1, onHandle }), + ); + const root = await render(app(a.onHandle)); + const handle = a.calls[0]!; + const element = overlay(); + const replay = vi.spyOn(handle, "replay"); + const show = vi.spyOn(handle, "show"); + const hide = vi.spyOn(handle, "hide"); + const destroy = vi.spyOn(handle, "destroy"); + + await act(() => root.render(app(b.onHandle))); + expect(a.calls).toEqual([handle, null]); + expect(b.calls).toEqual([handle]); + expect(overlay()).toBe(element); + expect(replay).not.toHaveBeenCalled(); + expect(show).not.toHaveBeenCalled(); + expect(hide).not.toHaveBeenCalled(); + expect(destroy).not.toHaveBeenCalled(); + // The new owner, not the old one, receives the null on unmount. + await act(() => root.unmount()); + expect(b.calls).toEqual([handle, null]); +}); + +it("still delivers the handle when the outgoing callback throws", async () => { + const target = createRef(); + const seen: (StetHandle | null)[] = []; + const oldCallback: Callback = (handle) => { + if (handle === null) throw new Error("old callback failed"); + seen.push(handle); + }; + const next = collector(); + const app = (onHandle: Callback) => + createElement( + Fragment, + null, + createElement("button", { ref: target }, "Save"), + createElement(Circle, { target, seed: 1, onHandle }), + ); + const root = await render(app(oldCallback)); + const handle = seen[0]!; + + let error: unknown; + try { + await act(() => root.render(app(next.onHandle))); + } catch (caught) { + error = caught; + } + // The callback error is not swallowed... + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("old callback failed"); + // ...and the new owner is still notified of the existing handle, then owns + // the teardown null when React unmounts the errored tree. + expect(next.calls).toEqual([handle, null]); + expect(document.querySelector(".stet-overlay")).toBeNull(); +}); + +it("notifies only when a callback appears or disappears", async () => { + const target = createRef(); + const { calls, onHandle } = collector(); + const app = (callback?: Callback) => + createElement( + Fragment, + null, + createElement("button", { ref: target }, "Save"), + createElement(Circle, { target, seed: 1, onHandle: callback }), + ); + const root = await render(app()); + expect(calls).toHaveLength(0); + + await act(() => root.render(app(onHandle))); + const handle = calls[0]!; + await act(() => root.render(app())); + expect(calls).toEqual([handle, null]); + await act(() => root.render(app(onHandle))); + expect(calls).toEqual([handle, null, handle]); + await act(() => root.unmount()); +}); + +it("replaces the handle when a stable ref points at a new DOM node", async () => { + const target = createRef(); + const { calls, onHandle } = collector(); + const app = (tag: string) => + createElement( + Fragment, + null, + createElement(tag, { ref: target }, "Save"), + createElement(Circle, { target, seed: 1, description: "D", onHandle }), + ); + const root = await render(app("button")); + const first = calls[0]!; + const firstOverlay = overlay(); + const oldNode = target.current!; + expect(oldNode.getAttribute("aria-describedby")).toContain("stet-description-"); + + await act(() => root.render(app("a"))); + expect(calls[0]).toBe(first); + expect(calls[1]).toBeNull(); + const second = calls[2]!; + expect(second).not.toBe(first); + expect(calls).toHaveLength(3); + expect(overlay()).not.toBe(firstOverlay); + expect(oldNode.hasAttribute("aria-describedby")).toBe(false); + expect(target.current!.getAttribute("aria-describedby")).toContain("stet-description-"); + await act(() => root.unmount()); +}); + +it("attaches a replacement with current declarative visibility, not imperative state", async () => { + const target = createRef(); + const { calls, onHandle } = collector(); + const app = (tag: string) => + createElement( + Fragment, + null, + createElement(tag, { ref: target }, "Save"), + createElement(Circle, { target, seed: 1, onHandle }), + ); + const root = await render(app("button")); + calls[0]!.hide(); + expect(overlay().hidden).toBe(true); + + await act(() => root.render(app("a"))); + expect(overlay().hidden).toBe(false); + expect(calls[1]).toBeNull(); + expect(calls[2]).not.toBe(calls[0]); + await act(() => root.unmount()); +}); + +it("does not notify null before a handle ever exists", async () => { + const target = createRef(); + const { calls, onHandle } = collector(); + const app = (present: boolean) => + createElement( + Fragment, + null, + present && createElement("button", { ref: target }, "Save"), + createElement(Circle, { target, onHandle }), + ); + const root = await render(app(false)); + expect(calls).toHaveLength(0); + expect(document.querySelector(".stet-overlay")).toBeNull(); + + await act(() => root.render(app(true))); + expect(calls).toHaveLength(1); + expect(calls[0]).not.toBeNull(); + await act(() => root.unmount()); +}); + +it("updates an arrow label in place and replaces the handle when 'to' changes", async () => { + const from = createRef(); + const to = createRef(); + const { calls, onHandle } = collector(); + const app = (toTag: string, label: string) => + createElement( + Fragment, + null, + createElement("button", { ref: from }, "From"), + createElement(toTag, { ref: to }, "To"), + createElement(Arrow, { from, to, label, seed: 1, curvature: -0.3, onHandle }), + ); + const root = await render(app("button", "One")); + const handle = calls[0]!; + expect(document.querySelector(".stet-label")?.textContent).toBe("One"); + + await act(() => root.render(app("button", "Two"))); + expect(document.querySelector(".stet-label")?.textContent).toBe("Two"); + expect(calls).toEqual([handle]); + + await act(() => root.render(app("a", "Two"))); + expect(calls[0]).toBe(handle); + expect(calls[1]).toBeNull(); + expect(calls[2]).not.toBe(handle); + expect(calls).toHaveLength(3); + await act(() => root.unmount()); +}); + +it("attaches an Arrow replacement even when the outgoing owner throws on null", async () => { + const from = createRef(); + const to = createRef(); + const seen: StetHandle[] = []; + const outgoing: Callback = (handle) => { + if (handle === null) throw new Error("outgoing failed"); + seen.push(handle); + }; + const next = collector(); + const app = (toTag: string, onHandle: Callback) => + createElement( + Fragment, + null, + createElement("button", { ref: from }, "From"), + createElement(toTag, { ref: to }, "To"), + createElement(Arrow, { from, to, label: "L", seed: 1, onHandle }), + ); + const root = await render(app("button", outgoing)); + const handle = seen[0]!; + const firstOverlay = overlay(); + const oldNode = to.current!; + expect(oldNode.getAttribute("aria-describedby")).toContain("stet-description-"); + + let error: unknown; + try { + await act(() => root.render(app("a", next.onHandle))); + } catch (caught) { + error = caught; + } + // The outgoing exception is surfaced, not swallowed... + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("outgoing failed"); + expect(seen).toEqual([handle]); + // ...the old target was torn down, and a fresh replacement still attached and + // reached the new owner despite the throw. + expect(oldNode.hasAttribute("aria-describedby")).toBe(false); + expect(firstOverlay.isConnected).toBe(false); + expect(next.calls).toHaveLength(2); + const replacement = next.calls[0]!; + expect(replacement).not.toBeNull(); + expect(replacement).not.toBe(handle); + // React unmounts the errored tree, so the new owner receives the teardown null. + expect(next.calls).toEqual([replacement, null]); + expect(document.querySelector(".stet-overlay")).toBeNull(); +}); + +it("updates mark kind in place without remount", async () => { + const target = createRef(); + const { calls, onHandle } = collector(); + const app = (kind: "right" | "wrong") => + createElement( + Fragment, + null, + createElement("button", { ref: target }, "Save"), + createElement(Mark, { target, kind, seed: 1, onHandle }), + ); + const root = await render(app("right")); + const handle = calls[0]!; + expect(document.querySelector(".stet-mark--right")).not.toBeNull(); + + await act(() => root.render(app("wrong"))); + expect(document.querySelector(".stet-mark--wrong")).not.toBeNull(); + expect(document.querySelector(".stet-mark--right")).toBeNull(); + expect(calls).toEqual([handle]); + await act(() => root.unmount()); +}); + +it("updates sticky text in place without remount", async () => { + const target = createRef(); + const { calls, onHandle } = collector(); + const app = (text: string) => + createElement( + Fragment, + null, + createElement("button", { ref: target }, "Save"), + createElement(Sticky, { target, text, seed: 1, onHandle }), + ); + const root = await render(app("One")); + const handle = calls[0]!; + expect(document.querySelector(".stet-sticky-text")?.textContent).toBe("One"); + + await act(() => root.render(app("Two"))); + expect(document.querySelector(".stet-sticky-text")?.textContent).toBe("Two"); + expect(calls).toEqual([handle]); + await act(() => root.unmount()); +}); diff --git a/tests/rough.test.ts b/tests/rough.test.ts index 86c4550..630c09b 100644 --- a/tests/rough.test.ts +++ b/tests/rough.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { mulberry32 } from "../src/prng.js"; -import { arrowGeometry, markerWash, roughArrow, roughCheckmark, roughEllipse, roughLine, variants } from "../src/rough.js"; +import { arrowGeometry, markerWash, roughArrow, roughBox, roughCheckmark, roughEllipse, roughLine, variants } from "../src/rough.js"; const options = { seed: 123, roughness: 1, boil: 0.3 }; @@ -9,6 +9,32 @@ describe("rough geometry", () => { expect(roughLine(0, 0, 100, 20, options)).toBe(roughLine(0, 0, 100, 20, options)); expect(roughEllipse(50, 20, 50, 20, options)).toBe(roughEllipse(50, 20, 50, 20, options)); expect(roughArrow(0, 0, 100, 20, options)).toBe(roughArrow(0, 0, 100, 20, options)); + expect(roughBox(0, 0, 100, 60, options)).toBe(roughBox(0, 0, 100, 60, options)); + }); + + it("varies boxes by seed and roughness", () => { + const base = roughBox(0, 0, 80, 40, options); + expect(roughBox(0, 0, 80, 40, { ...options, seed: 124 })).not.toBe(base); + expect(roughBox(0, 0, 80, 40, { ...options, roughness: 3 })).not.toBe(base); + }); + + it("draws one open box path with a finishing overlap", () => { + const path = roughBox(0, 0, 100, 60, options); + expect(path.match(/M/g)).toHaveLength(1); + expect(path).not.toContain("Z"); + // Four sides plus the retraced top edge. + expect(path.match(/C/g)).toHaveLength(5); + const numbers = (path.match(/-?\d+\.\d+/g) ?? []).map(Number); + const [startX, startY] = numbers; + const [endX, endY] = numbers.slice(-2); + expect(endX).toBeGreaterThan(startX + 1); + expect(Math.abs(endY - startY)).toBeLessThan(3); + }); + + it.each([0, 1, 42, 2147483647])("keeps zero and hairline boxes finite for seed %s", seed => { + const o = { ...options, seed }; + for (const [w, h] of [[0, 0], [0, 20], [20, 0], [0.5, 0.5], [1, 400]] as const) + expect(roughBox(10, 10, w, h, o)).not.toMatch(/NaN|Infinity/); }); it("generates deterministic boil variants", () => { @@ -39,7 +65,8 @@ describe("rough geometry", () => { const o = { ...options, seed }; for (const size of [0, 1, 24, 400, 1600]) { const paths = [roughEllipse(0, 0, size, size / 4, o), roughLine(0, 0, size, 0, o), - roughArrow(0, 0, size, size, o), markerWash(0, 0, size, 20, o), roughCheckmark(0, 0, 25, 23, o)]; + roughArrow(0, 0, size, size, o), markerWash(0, 0, size, 20, o), roughCheckmark(0, 0, 25, 23, o), + roughBox(0, 0, size, size / 2, o)]; expect(paths.join("")).not.toMatch(/NaN|Infinity/); } }); diff --git a/tests/shipping/fixture/apps/disabled/index.html b/tests/shipping/fixture/apps/disabled/index.html new file mode 100644 index 0000000..4923b4d --- /dev/null +++ b/tests/shipping/fixture/apps/disabled/index.html @@ -0,0 +1 @@ +Idle diff --git a/tests/shipping/fixture/apps/disabled/main.ts b/tests/shipping/fixture/apps/disabled/main.ts new file mode 100644 index 0000000..a5b7c5d --- /dev/null +++ b/tests/shipping/fixture/apps/disabled/main.ts @@ -0,0 +1,2 @@ +import { mountApp } from "../../src/app"; +mountApp(); diff --git a/tests/shipping/fixture/apps/mixed/index.html b/tests/shipping/fixture/apps/mixed/index.html new file mode 100644 index 0000000..4923b4d --- /dev/null +++ b/tests/shipping/fixture/apps/mixed/index.html @@ -0,0 +1 @@ +Idle diff --git a/tests/shipping/fixture/apps/mixed/main.ts b/tests/shipping/fixture/apps/mixed/main.ts new file mode 100644 index 0000000..d0b204b --- /dev/null +++ b/tests/shipping/fixture/apps/mixed/main.ts @@ -0,0 +1,6 @@ +import { mountApp } from "../../src/app"; +import { mountProduction } from "../../src/stet/production"; +import { mountReviewBoundary } from "../../src/stet/review-boundary"; +mountApp(); +mountProduction(); +void mountReviewBoundary(); diff --git a/tests/shipping/fixture/apps/production/index.html b/tests/shipping/fixture/apps/production/index.html new file mode 100644 index 0000000..4923b4d --- /dev/null +++ b/tests/shipping/fixture/apps/production/index.html @@ -0,0 +1 @@ +Idle diff --git a/tests/shipping/fixture/apps/production/main.ts b/tests/shipping/fixture/apps/production/main.ts new file mode 100644 index 0000000..bed0e5d --- /dev/null +++ b/tests/shipping/fixture/apps/production/main.ts @@ -0,0 +1,4 @@ +import { mountApp } from "../../src/app"; +import { mountProduction } from "../../src/stet/production"; +mountApp(); +mountProduction(); diff --git a/tests/shipping/fixture/apps/review/index.html b/tests/shipping/fixture/apps/review/index.html new file mode 100644 index 0000000..4923b4d --- /dev/null +++ b/tests/shipping/fixture/apps/review/index.html @@ -0,0 +1 @@ +Idle diff --git a/tests/shipping/fixture/apps/review/main.ts b/tests/shipping/fixture/apps/review/main.ts new file mode 100644 index 0000000..f0d4dfe --- /dev/null +++ b/tests/shipping/fixture/apps/review/main.ts @@ -0,0 +1,4 @@ +import { mountApp } from "../../src/app"; +import { mountReviewBoundary } from "../../src/stet/review-boundary"; +mountApp(); +void mountReviewBoundary(); diff --git a/tests/shipping/fixture/config-base.mjs b/tests/shipping/fixture/config-base.mjs new file mode 100644 index 0000000..968a9d9 --- /dev/null +++ b/tests/shipping/fixture/config-base.mjs @@ -0,0 +1,20 @@ +import { fileURLToPath } from "node:url"; + +export function makeConfig(policy, app) { + if (!new Set(["off", "preview", "local"]).has(policy)) + throw new Error(`Unknown Stet review policy: ${policy}`); + return ({ command, mode }) => ({ + root: fileURLToPath(new URL(`apps/${app}/`, import.meta.url)), + base: "./", + define: { + __STET_REVIEW_BUILD__: JSON.stringify( + policy === "preview" + ? mode === "stet-preview" + : policy === "local" + ? command === "serve" + : false, + ), + }, + build: { manifest: true, sourcemap: "hidden" }, + }); +} diff --git a/tests/shipping/fixture/src/app.ts b/tests/shipping/fixture/src/app.ts new file mode 100644 index 0000000..5430904 --- /dev/null +++ b/tests/shipping/fixture/src/app.ts @@ -0,0 +1,6 @@ +export function mountApp(): void { + document.querySelector("#target")!.addEventListener("click", () => { + document.querySelector("#status")!.value = "Clicked"; + }); + document.documentElement.dataset.ready = "true"; +} diff --git a/tests/shipping/fixture/src/stet/production.ts b/tests/shipping/fixture/src/stet/production.ts new file mode 100644 index 0000000..7d64d7d --- /dev/null +++ b/tests/shipping/fixture/src/stet/production.ts @@ -0,0 +1,12 @@ +import { circle } from "@funsaized/stet"; +import "@funsaized/stet/style.css"; + +export const productionSentinel = "STET_PRODUCTION_SENTINEL"; + +export function mountProduction(): () => void { + const handle = circle(document.querySelector("#target")!, { + seed: 11, + description: productionSentinel, + }); + return () => handle.destroy(); +} diff --git a/tests/shipping/fixture/src/stet/review-boundary.ts b/tests/shipping/fixture/src/stet/review-boundary.ts new file mode 100644 index 0000000..df10cd5 --- /dev/null +++ b/tests/shipping/fixture/src/stet/review-boundary.ts @@ -0,0 +1,7 @@ +declare const __STET_REVIEW_BUILD__: boolean; + +export async function mountReviewBoundary(): Promise<() => void> { + if (!__STET_REVIEW_BUILD__) return () => {}; + const { mountReview } = await import("./review"); + return mountReview(); +} diff --git a/tests/shipping/fixture/src/stet/review-plan.json b/tests/shipping/fixture/src/stet/review-plan.json new file mode 100644 index 0000000..f801738 --- /dev/null +++ b/tests/shipping/fixture/src/stet/review-plan.json @@ -0,0 +1 @@ +{ "sentinel": "STET_REVIEW_PLAN_SENTINEL" } diff --git a/tests/shipping/fixture/src/stet/review.css b/tests/shipping/fixture/src/stet/review.css new file mode 100644 index 0000000..3805e6a --- /dev/null +++ b/tests/shipping/fixture/src/stet/review.css @@ -0,0 +1 @@ +:root { --stet-review-css-sentinel: "STET_REVIEW_CSS_SENTINEL"; } diff --git a/tests/shipping/fixture/src/stet/review.ts b/tests/shipping/fixture/src/stet/review.ts new file mode 100644 index 0000000..5986a4e --- /dev/null +++ b/tests/shipping/fixture/src/stet/review.ts @@ -0,0 +1,16 @@ +import { underline } from "@funsaized/stet"; +import "@funsaized/stet/style.css"; +import "./review.css"; +import plan from "./review-plan.json"; + +export const reviewModuleSentinel = "STET_REVIEW_MODULE_SENTINEL"; +export const reviewCopySentinel = "STET_REVIEW_COPY_SENTINEL"; + +export function mountReview(): () => void { + const handle = underline(document.querySelector("#target")!, { + seed: 22, + description: `${reviewCopySentinel}:${plan.sentinel}`, + }); + document.documentElement.dataset.reviewModule = reviewModuleSentinel; + return () => handle.destroy(); +} diff --git a/tests/shipping/fixture/vite.disabled.config.mjs b/tests/shipping/fixture/vite.disabled.config.mjs new file mode 100644 index 0000000..37aa632 --- /dev/null +++ b/tests/shipping/fixture/vite.disabled.config.mjs @@ -0,0 +1,2 @@ +import { makeConfig } from "./config-base.mjs"; +export default makeConfig("off", "disabled"); diff --git a/tests/shipping/fixture/vite.local.config.mjs b/tests/shipping/fixture/vite.local.config.mjs new file mode 100644 index 0000000..b965723 --- /dev/null +++ b/tests/shipping/fixture/vite.local.config.mjs @@ -0,0 +1,2 @@ +import { makeConfig } from "./config-base.mjs"; +export default makeConfig("local", "review"); diff --git a/tests/shipping/fixture/vite.mixed-local.config.mjs b/tests/shipping/fixture/vite.mixed-local.config.mjs new file mode 100644 index 0000000..429390c --- /dev/null +++ b/tests/shipping/fixture/vite.mixed-local.config.mjs @@ -0,0 +1,2 @@ +import { makeConfig } from "./config-base.mjs"; +export default makeConfig("local", "mixed"); diff --git a/tests/shipping/fixture/vite.mixed-preview.config.mjs b/tests/shipping/fixture/vite.mixed-preview.config.mjs new file mode 100644 index 0000000..a057539 --- /dev/null +++ b/tests/shipping/fixture/vite.mixed-preview.config.mjs @@ -0,0 +1,2 @@ +import { makeConfig } from "./config-base.mjs"; +export default makeConfig("preview", "mixed"); diff --git a/tests/shipping/fixture/vite.preview.config.mjs b/tests/shipping/fixture/vite.preview.config.mjs new file mode 100644 index 0000000..1856c21 --- /dev/null +++ b/tests/shipping/fixture/vite.preview.config.mjs @@ -0,0 +1,2 @@ +import { makeConfig } from "./config-base.mjs"; +export default makeConfig("preview", "review"); diff --git a/tests/shipping/fixture/vite.production.config.mjs b/tests/shipping/fixture/vite.production.config.mjs new file mode 100644 index 0000000..450a965 --- /dev/null +++ b/tests/shipping/fixture/vite.production.config.mjs @@ -0,0 +1,2 @@ +import { makeConfig } from "./config-base.mjs"; +export default makeConfig("off", "production"); diff --git a/tests/shipping/inspect-output.mjs b/tests/shipping/inspect-output.mjs new file mode 100644 index 0000000..2a5ef61 --- /dev/null +++ b/tests/shipping/inspect-output.mjs @@ -0,0 +1,153 @@ +import { cpSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const work = fileURLToPath(new URL("../../test-results/shipping/", import.meta.url)); +const builds = join(work, "consumer/builds"); +const reviewNeedles = [ + "STET_REVIEW_MODULE_SENTINEL", + "STET_REVIEW_COPY_SENTINEL", + "STET_REVIEW_PLAN_SENTINEL", + "STET_REVIEW_CSS_SENTINEL", +]; +const runtimeNeedles = ["stet-overlay", "stet-description-", "--stet-stroke"]; +const isReviewPath = (value) => + /[/\\]stet[/\\]review(?:\.|-)/.test(value) && + !/[/\\]stet[/\\]review-boundary(?:\.ts)?$/.test(value); +const strings = (value) => + typeof value === "string" + ? [value] + : Array.isArray(value) + ? value.flatMap(strings) + : value && typeof value === "object" + ? Object.entries(value).flatMap(([key, entry]) => [key, ...strings(entry)]) + : []; + +function files(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + return entry.isDirectory() ? files(path) : [path]; + }); +} + +function inspect(name, { review, production, runtime, publishedMaps }) { + const dir = join(builds, name); + const inventory = files(dir).map((path) => ({ path, content: readFileSync(path, "utf8") })); + const assertNeedles = (needles, expected, claim) => { + for (const needle of needles) { + const match = inventory.find(({ content }) => content.includes(needle)); + if (Boolean(match) !== expected) + throw new Error( + `${name}: ${claim} ${expected ? "missing" : "leaked"} ${needle}${match ? ` in ${relative(dir, match.path)}` : ""}`, + ); + } + }; + assertNeedles(reviewNeedles, review, "review content"); + assertNeedles(["STET_PRODUCTION_SENTINEL"], production, "production content"); + assertNeedles(runtimeNeedles, runtime, "Stet runtime"); + const manifest = inventory.find(({ path }) => path.endsWith("/.vite/manifest.json")); + if (!manifest) throw new Error(`${name}: missing Vite manifest`); + if (!review && strings(JSON.parse(manifest.content)).some(isReviewPath)) + throw new Error(`${name}: manifest retained a review module or chunk`); + const mapFiles = inventory.filter(({ path }) => path.endsWith(".map")); + if (mapFiles.length === 0) + throw new Error( + `${name}: ${publishedMaps ? "published" : "hidden"} source maps were not discovered by listing output`, + ); + const mapLinkPublished = inventory + .filter(({ path }) => path.endsWith(".js")) + .some(({ content }) => content.includes("sourceMappingURL=")); + if (mapLinkPublished !== publishedMaps) + throw new Error(`${name}: expected ${publishedMaps ? "published" : "hidden"} source-map links`); + for (const { path, content } of mapFiles) { + const map = JSON.parse(content); + const decoded = JSON.stringify({ + sources: map.sources, + sourcesContent: map.sourcesContent, + names: map.names, + }); + if ( + !review && + (map.sources.some(isReviewPath) || reviewNeedles.some((needle) => decoded.includes(needle))) + ) + throw new Error(`${name}: review source leaked in ${relative(dir, path)}`); + } + return { name, files: inventory.map(({ path }) => relative(dir, path)) }; +} + +export function scanShippingOutput() { + return [ + inspect("production", { review: false, production: true, runtime: true, publishedMaps: false }), + inspect("preview-preview", { + review: true, + production: false, + runtime: true, + publishedMaps: false, + }), + inspect("preview-customer", { + review: false, + production: false, + runtime: false, + publishedMaps: false, + }), + inspect("preview-customer-map", { + review: false, + production: false, + runtime: false, + publishedMaps: true, + }), + inspect("local-build", { + review: false, + production: false, + runtime: false, + publishedMaps: false, + }), + inspect("local-build-map", { + review: false, + production: false, + runtime: false, + publishedMaps: true, + }), + inspect("mixed-customer", { + review: false, + production: true, + runtime: true, + publishedMaps: false, + }), + inspect("mixed-customer-map", { + review: false, + production: true, + runtime: true, + publishedMaps: true, + }), + inspect("mixed-preview", { + review: true, + production: true, + runtime: true, + publishedMaps: false, + }), + ]; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const inventory = scanShippingOutput(); + const broken = join(builds, "deliberately-broken"); + rmSync(broken, { recursive: true, force: true }); + mkdirSync(broken, { recursive: true }); + cpSync(join(builds, "preview-customer"), broken, { recursive: true }); + writeFileSync(join(broken, "leak.txt"), reviewNeedles[1]); + let detected = ""; + try { + inspect("deliberately-broken", { + review: false, + production: false, + runtime: false, + publishedMaps: false, + }); + } catch (error) { + detected = String(error); + } + if (!detected.includes("leak.txt") || !detected.includes(reviewNeedles[1])) + throw new Error(`Deliberate leak was not identified: ${detected}`); + console.log(JSON.stringify({ ok: true, inventory, deliberateFailure: detected }, null, 2)); +} diff --git a/tests/shipping/prepare.mjs b/tests/shipping/prepare.mjs new file mode 100644 index 0000000..fc09410 --- /dev/null +++ b/tests/shipping/prepare.mjs @@ -0,0 +1,86 @@ +import { execFileSync } from "node:child_process"; +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); +const work = join(root, "test-results/shipping"); +const consumer = join(work, "consumer"); +const cache = join(work, "npm-cache"); +const fixture = join(root, "tests/shipping/fixture"); +const vite = join(root, "node_modules/vite/bin/vite.js"); +const run = (command, args, options = {}) => + execFileSync(command, args, { encoding: "utf8", ...options }); + +rmSync(consumer, { recursive: true, force: true }); +mkdirSync(consumer, { recursive: true }); +cpSync(fixture, consumer, { recursive: true }); +writeFileSync( + join(consumer, "package.json"), + JSON.stringify({ name: "stet-shipping-consumer", private: true, type: "module" }), +); +const [pack] = JSON.parse( + run("npm", ["pack", "--ignore-scripts", "--json", "--cache", cache, "--pack-destination", work], { + cwd: root, + }), +); +run( + "npm", + [ + "install", + join(work, pack.filename), + "--ignore-scripts", + "--offline", + "--no-audit", + "--no-fund", + "--cache", + cache, + ], + { cwd: consumer }, +); + +const builds = [ + ["production", "vite.production.config.mjs", "production"], + ["preview-customer", "vite.preview.config.mjs", "production"], + ["preview-customer-map", "vite.preview.config.mjs", "production", "true"], + ["preview-preview", "vite.preview.config.mjs", "stet-preview"], + ["local-build", "vite.local.config.mjs", "production"], + ["local-build-map", "vite.local.config.mjs", "production", "true"], + ["disabled", "vite.disabled.config.mjs", "production"], + ["mixed-customer", "vite.mixed-preview.config.mjs", "production"], + ["mixed-customer-map", "vite.mixed-preview.config.mjs", "production", "true"], + ["mixed-preview", "vite.mixed-preview.config.mjs", "stet-preview"], +]; +for (const [name, config, mode, sourcemap] of builds) { + const out = join(consumer, "builds", name); + run( + process.execPath, + [ + vite, + "build", + "--config", + join(consumer, config), + "--mode", + mode, + "--outDir", + out, + "--emptyOutDir", + ...(sourcemap ? ["--sourcemap", sourcemap] : []), + ], + { cwd: consumer, stdio: "inherit" }, + ); +} +writeFileSync( + join(work, "env.json"), + JSON.stringify( + { + tarball: pack.filename, + bytes: pack.size, + files: pack.files.length, + builds: builds.map(([name]) => name), + }, + null, + 2, + ), +); +console.log(JSON.stringify(JSON.parse(readFileSync(join(work, "env.json"), "utf8")), null, 2)); diff --git a/tests/shipping/runtime.spec.ts b/tests/shipping/runtime.spec.ts new file mode 100644 index 0000000..d34c2bb --- /dev/null +++ b/tests/shipping/runtime.spec.ts @@ -0,0 +1,29 @@ +import { expect, test, type Page } from "@playwright/test"; + +async function verify(page: Page, url: string, production: number, review: number) { + await page.goto(url); + await page.waitForFunction(() => document.documentElement.dataset.ready === "true"); + await expect(page.locator(".stet-overlay--circle")).toHaveCount(production); + await expect(page.locator(".stet-overlay--underline")).toHaveCount(review); + await page.getByRole("button", { name: "Run action" }).click(); + await expect(page.locator("#status")).toHaveText("Clicked"); +} + +for (const [name, production, review] of [ + ["production", 1, 0], + ["preview-customer", 0, 0], + ["preview-preview", 0, 1], + ["local-build", 0, 0], + ["disabled", 0, 0], + ["mixed-customer", 1, 0], + ["mixed-preview", 1, 1], +] as const) { + test(`${name} build renders the contracted annotations and native behavior`, ({ page }) => + verify(page, `http://127.0.0.1:4300/${name}/`, production, review)); +} + +test("local-only development renders review annotations", ({ page }) => + verify(page, "http://127.0.0.1:4301/", 0, 1)); + +test("mixed local development retains production and review annotations", ({ page }) => + verify(page, "http://127.0.0.1:4302/", 1, 1)); diff --git a/tests/stet.test.ts b/tests/stet.test.ts index cb5a89a..c5e60c1 100644 --- a/tests/stet.test.ts +++ b/tests/stet.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { arrow, + box, circle, highlight, mark, @@ -70,6 +71,7 @@ describe("attachers", () => { }); it.each([ + ["box", (node: Element) => box(node, { seed: 42 })], ["circle", (node: Element) => circle(node, { seed: 42 })], ["underline", (node: Element) => underline(node, { seed: 42 })], ["highlight", (node: Element) => highlight(node, { seed: 42 })], @@ -320,18 +322,17 @@ describe("animation options", () => { handle.destroy(); }); - it("accepts enabled animation options for circle and underline", () => { - for (const attach of [circle, underline]) { + it("accepts enabled animation options for box, circle, and underline", () => { + for (const attach of [box, circle, underline]) { const handle = attach(element(), { animate: true, animationDuration: 0, animationDelay: 0 }); handle.destroy(); } }); }); -// CORE-04: circle rides the exact same path-length mechanism as underline, so -// every parity case runs for both against identical expectations. -describe.each(["circle", "underline"] as const)("%s reveal parity", (primitive) => { - const attach = primitive === "circle" ? circle : underline; +// Stroked primitives ride the same path-length mechanism. +describe.each(["box", "circle", "underline"] as const)("%s reveal parity", (primitive) => { + const attach = { box, circle, underline }[primitive]; const path = () => document.querySelector(`.stet-overlay--${primitive} path`)!; const offset = () => path().getAttribute("stroke-dashoffset"); const overlay = () => document.querySelector(`.stet-overlay--${primitive}`)!; @@ -485,296 +486,299 @@ describe.each(["circle", "underline"] as const)("%s reveal parity", (primitive) // CORE-05: one async operation per handle. Every row of the runtime contract // transition table runs for both stroked primitives against identical behavior. -describe.each(["circle", "underline"] as const)("%s operation state machine", (primitive) => { - const attach = primitive === "circle" ? circle : underline; - const overlay = () => document.querySelector(`.stet-overlay--${primitive}`)!; - const path = () => document.querySelector(`.stet-overlay--${primitive} path`)!; - const offset = () => path().getAttribute("stroke-dashoffset"); - const reducedMotion = (initial: boolean) => { - let matches = initial; - const listeners = new Set<() => void>(); - return { - get matches() { - return matches; - }, - addEventListener: (_type: string, listener: () => void) => { - listeners.add(listener); - }, - removeEventListener: (_type: string, listener: () => void) => { - listeners.delete(listener); - }, - set(value: boolean) { - matches = value; - for (const listener of listeners) listener(); - }, +describe.each(["box", "circle", "underline"] as const)( + "%s operation state machine", + (primitive) => { + const attach = { box, circle, underline }[primitive]; + const overlay = () => document.querySelector(`.stet-overlay--${primitive}`)!; + const path = () => document.querySelector(`.stet-overlay--${primitive} path`)!; + const offset = () => path().getAttribute("stroke-dashoffset"); + const reducedMotion = (initial: boolean) => { + let matches = initial; + const listeners = new Set<() => void>(); + return { + get matches() { + return matches; + }, + addEventListener: (_type: string, listener: () => void) => { + listeners.add(listener); + }, + removeEventListener: (_type: string, listener: () => void) => { + listeners.delete(listener); + }, + set(value: boolean) { + matches = value; + for (const listener of listeners) listener(); + }, + }; }; - }; - it("joins the attach operation exactly and returns a distinct finished show when settled", async () => { - vi.useFakeTimers(); - const handle = attach(element(), { seed: 1, animate: true, animationDuration: 200 }); - expect(overlay().hidden).toBe(false); - expect(offset()).toBe("1"); - const joined = handle.show(); - expect(handle.show()).toBe(joined); - handle.refresh(); - handle.resketch(2); - expect(handle.show()).toBe(joined); - vi.advanceTimersByTime(250); - await expect(joined).resolves.toEqual({ status: "finished" }); - // Settled-visible show is a new fulfilled result and must not redraw. - const settledPath = path(); - const settled = handle.show(); - expect(settled).not.toBe(joined); - await expect(settled).resolves.toEqual({ status: "finished" }); - expect(path()).toBe(settledPath); - handle.destroy(); - }); + it("joins the attach operation exactly and returns a distinct finished show when settled", async () => { + vi.useFakeTimers(); + const handle = attach(element(), { seed: 1, animate: true, animationDuration: 200 }); + expect(overlay().hidden).toBe(false); + expect(offset()).toBe("1"); + const joined = handle.show(); + expect(handle.show()).toBe(joined); + handle.refresh(); + handle.resketch(2); + expect(handle.show()).toBe(joined); + vi.advanceTimersByTime(250); + await expect(joined).resolves.toEqual({ status: "finished" }); + // Settled-visible show is a new fulfilled result and must not redraw. + const settledPath = path(); + const settled = handle.show(); + expect(settled).not.toBe(joined); + await expect(settled).resolves.toEqual({ status: "finished" }); + expect(path()).toBe(settledPath); + handle.destroy(); + }); - it("returns the same promise while delaying and reveals at the deadline", async () => { - vi.useFakeTimers(); - const handle = attach(element(), { - seed: 1, - animate: true, - animationDelay: 100, - animationDuration: 200, + it("returns the same promise while delaying and reveals at the deadline", async () => { + vi.useFakeTimers(); + const handle = attach(element(), { + seed: 1, + animate: true, + animationDelay: 100, + animationDuration: 200, + }); + expect(offset()).toBe("1"); + const joined = handle.show(); + expect(handle.show()).toBe(joined); + vi.advanceTimersByTime(120); + const mid = Number(offset()); + expect(mid).toBeGreaterThan(0); + expect(mid).toBeLessThan(1); + vi.advanceTimersByTime(250); + await expect(joined).resolves.toEqual({ status: "finished" }); + handle.destroy(); }); - expect(offset()).toBe("1"); - const joined = handle.show(); - expect(handle.show()).toBe(joined); - vi.advanceTimersByTime(120); - const mid = Number(offset()); - expect(mid).toBeGreaterThan(0); - expect(mid).toBeLessThan(1); - vi.advanceTimersByTime(250); - await expect(joined).resolves.toEqual({ status: "finished" }); - handle.destroy(); - }); - it("starts an operation on show after hidden attach and restores ARIA", async () => { - vi.useFakeTimers(); - const node = element(); - const handle = attach(node, { - seed: 1, - visible: false, - animate: true, - animationDuration: 200, - description: "Required", + it("starts an operation on show after hidden attach and restores ARIA", async () => { + vi.useFakeTimers(); + const node = element(); + const handle = attach(node, { + seed: 1, + visible: false, + animate: true, + animationDuration: 200, + description: "Required", + }); + expect(overlay().hidden).toBe(true); + expect(node.hasAttribute("aria-describedby")).toBe(false); + const reveal = handle.show(); + expect(overlay().hidden).toBe(false); + expect(offset()).toBe("1"); + expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); + expect(handle.show()).toBe(reveal); + vi.advanceTimersByTime(250); + await expect(reveal).resolves.toEqual({ status: "finished" }); + handle.destroy(); }); - expect(overlay().hidden).toBe(true); - expect(node.hasAttribute("aria-describedby")).toBe(false); - const reveal = handle.show(); - expect(overlay().hidden).toBe(false); - expect(offset()).toBe("1"); - expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); - expect(handle.show()).toBe(reveal); - vi.advanceTimersByTime(250); - await expect(reveal).resolves.toEqual({ status: "finished" }); - handle.destroy(); - }); - it("starts a fresh operation on show after explicit hide", async () => { - vi.useFakeTimers(); - const node = element(); - const handle = attach(node, { - seed: 1, - animate: true, - animationDuration: 200, - description: "Required", + it("starts a fresh operation on show after explicit hide", async () => { + vi.useFakeTimers(); + const node = element(); + const handle = attach(node, { + seed: 1, + animate: true, + animationDuration: 200, + description: "Required", + }); + vi.advanceTimersByTime(250); + handle.hide(); + expect(overlay().hidden).toBe(true); + expect(node.hasAttribute("aria-describedby")).toBe(false); + const reveal = handle.show(); + expect(overlay().hidden).toBe(false); + expect(offset()).toBe("1"); + expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); + vi.advanceTimersByTime(250); + await expect(reveal).resolves.toEqual({ status: "finished" }); + handle.destroy(); }); - vi.advanceTimersByTime(250); - handle.hide(); - expect(overlay().hidden).toBe(true); - expect(node.hasAttribute("aria-describedby")).toBe(false); - const reveal = handle.show(); - expect(overlay().hidden).toBe(false); - expect(offset()).toBe("1"); - expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); - vi.advanceTimersByTime(250); - await expect(reveal).resolves.toEqual({ status: "finished" }); - handle.destroy(); - }); - it("returns a new finished promise for settled-visible static show without redrawing", async () => { - const handle = attach(element(), { seed: 1 }); - const settledPath = path(); - const first = handle.show(); - const second = handle.show(); - expect(first).not.toBe(second); - await expect(first).resolves.toEqual({ status: "finished" }); - await expect(second).resolves.toEqual({ status: "finished" }); - expect(path()).toBe(settledPath); - handle.destroy(); - }); + it("returns a new finished promise for settled-visible static show without redrawing", async () => { + const handle = attach(element(), { seed: 1 }); + const settledPath = path(); + const first = handle.show(); + const second = handle.show(); + expect(first).not.toBe(second); + await expect(first).resolves.toEqual({ status: "finished" }); + await expect(second).resolves.toEqual({ status: "finished" }); + expect(path()).toBe(settledPath); + handle.destroy(); + }); - it("replays a settled operation with the same seed and a distinct promise", async () => { - vi.useFakeTimers(); - const handle = attach(element(), { seed: 1, animate: true, animationDuration: 200 }); - vi.advanceTimersByTime(250); - const seeded = path().getAttribute("d"); - const replay = handle.replay(); - expect(overlay().hidden).toBe(false); - expect(offset()).toBe("1"); - vi.advanceTimersByTime(250); - await expect(replay).resolves.toEqual({ status: "finished" }); - expect(path().getAttribute("d")).toBe(seeded); - handle.destroy(); - }); + it("replays a settled operation with the same seed and a distinct promise", async () => { + vi.useFakeTimers(); + const handle = attach(element(), { seed: 1, animate: true, animationDuration: 200 }); + vi.advanceTimersByTime(250); + const seeded = path().getAttribute("d"); + const replay = handle.replay(); + expect(overlay().hidden).toBe(false); + expect(offset()).toBe("1"); + vi.advanceTimersByTime(250); + await expect(replay).resolves.toEqual({ status: "finished" }); + expect(path().getAttribute("d")).toBe(seeded); + handle.destroy(); + }); - it("replays a static handle into a new finished promise at the same seed", async () => { - const handle = attach(element(), { seed: 1 }); - const seeded = path().getAttribute("d"); - const first = handle.replay(); - const second = handle.replay(); - expect(first).not.toBe(second); - await expect(first).resolves.toEqual({ status: "finished" }); - await expect(second).resolves.toEqual({ status: "finished" }); - expect(path().getAttribute("d")).toBe(seeded); - handle.destroy(); - }); + it("replays a static handle into a new finished promise at the same seed", async () => { + const handle = attach(element(), { seed: 1 }); + const seeded = path().getAttribute("d"); + const first = handle.replay(); + const second = handle.replay(); + expect(first).not.toBe(second); + await expect(first).resolves.toEqual({ status: "finished" }); + await expect(second).resolves.toEqual({ status: "finished" }); + expect(path().getAttribute("d")).toBe(seeded); + handle.destroy(); + }); - it("replays from explicit hide by restoring visibility and ARIA", async () => { - vi.useFakeTimers(); - const node = element(); - const handle = attach(node, { - seed: 1, - animate: true, - animationDuration: 200, - description: "Required", + it("replays from explicit hide by restoring visibility and ARIA", async () => { + vi.useFakeTimers(); + const node = element(); + const handle = attach(node, { + seed: 1, + animate: true, + animationDuration: 200, + description: "Required", + }); + vi.advanceTimersByTime(250); + handle.hide(); + expect(node.hasAttribute("aria-describedby")).toBe(false); + const replay = handle.replay(); + expect(overlay().hidden).toBe(false); + expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); + vi.advanceTimersByTime(250); + await expect(replay).resolves.toEqual({ status: "finished" }); + handle.destroy(); }); - vi.advanceTimersByTime(250); - handle.hide(); - expect(node.hasAttribute("aria-describedby")).toBe(false); - const replay = handle.replay(); - expect(overlay().hidden).toBe(false); - expect(node.getAttribute("aria-describedby")).toContain("stet-description-"); - vi.advanceTimersByTime(250); - await expect(replay).resolves.toEqual({ status: "finished" }); - handle.destroy(); - }); - it("supersedes an in-flight operation on replay, cancelling it before the replacement settles", async () => { - vi.useFakeTimers(); - const handle = attach(element(), { - seed: 1, - animate: true, - animationDelay: 200, - animationDuration: 400, + it("supersedes an in-flight operation on replay, cancelling it before the replacement settles", async () => { + vi.useFakeTimers(); + const handle = attach(element(), { + seed: 1, + animate: true, + animationDelay: 200, + animationDuration: 400, + }); + vi.advanceTimersByTime(100); + const old = handle.show(); + const order: string[] = []; + old.then(() => order.push("old")); + const next = handle.replay(); + expect(next).not.toBe(old); + next.then(() => order.push("new")); + // A following show joins the replacement, not the cancelled operation. + expect(handle.show()).toBe(next); + await vi.advanceTimersByTimeAsync(1000); + await expect(old).resolves.toEqual({ status: "cancelled" }); + await expect(next).resolves.toEqual({ status: "finished" }); + expect(order).toEqual(["old", "new"]); + handle.destroy(); }); - vi.advanceTimersByTime(100); - const old = handle.show(); - const order: string[] = []; - old.then(() => order.push("old")); - const next = handle.replay(); - expect(next).not.toBe(old); - next.then(() => order.push("new")); - // A following show joins the replacement, not the cancelled operation. - expect(handle.show()).toBe(next); - await vi.advanceTimersByTimeAsync(1000); - await expect(old).resolves.toEqual({ status: "cancelled" }); - await expect(next).resolves.toEqual({ status: "finished" }); - expect(order).toEqual(["old", "new"]); - handle.destroy(); - }); - it("cancels exactly once on hide and never settles finished late", async () => { - vi.useFakeTimers(); - const handle = attach(element(), { seed: 1, animate: true, animationDuration: 400 }); - const op = handle.show(); - const statuses: string[] = []; - op.then((result) => statuses.push(result.status)); - handle.hide(); - handle.hide(); - await expect(op).resolves.toEqual({ status: "cancelled" }); - await vi.advanceTimersByTimeAsync(2000); - handle.refresh(); - handle.resketch(5); - await Promise.resolve(); - expect(statuses).toEqual(["cancelled"]); - expect(offset()).toBeNull(); - handle.destroy(); - }); + it("cancels exactly once on hide and never settles finished late", async () => { + vi.useFakeTimers(); + const handle = attach(element(), { seed: 1, animate: true, animationDuration: 400 }); + const op = handle.show(); + const statuses: string[] = []; + op.then((result) => statuses.push(result.status)); + handle.hide(); + handle.hide(); + await expect(op).resolves.toEqual({ status: "cancelled" }); + await vi.advanceTimersByTimeAsync(2000); + handle.refresh(); + handle.resketch(5); + await Promise.resolve(); + expect(statuses).toEqual(["cancelled"]); + expect(offset()).toBeNull(); + handle.destroy(); + }); - it("cancels on destroy and returns fresh cancelled promises after teardown", async () => { - vi.useFakeTimers(); - const node = element(); - const handle = attach(node, { - seed: 1, - animate: true, - animationDuration: 400, - description: "Required", + it("cancels on destroy and returns fresh cancelled promises after teardown", async () => { + vi.useFakeTimers(); + const node = element(); + const handle = attach(node, { + seed: 1, + animate: true, + animationDuration: 400, + description: "Required", + }); + const op = handle.show(); + handle.destroy(); + await expect(op).resolves.toEqual({ status: "cancelled" }); + const show = handle.show(); + const replay = handle.replay(); + expect(show).not.toBe(replay); + await expect(show).resolves.toEqual({ status: "cancelled" }); + await expect(replay).resolves.toEqual({ status: "cancelled" }); + // Void calls are harmless no-ops after teardown. + handle.destroy(); + handle.hide(); + handle.refresh(); + handle.resketch(3); + expect(overlay()).toBeNull(); + expect(node.hasAttribute("aria-describedby")).toBe(false); + await vi.advanceTimersByTimeAsync(1000); }); - const op = handle.show(); - handle.destroy(); - await expect(op).resolves.toEqual({ status: "cancelled" }); - const show = handle.show(); - const replay = handle.replay(); - expect(show).not.toBe(replay); - await expect(show).resolves.toEqual({ status: "cancelled" }); - await expect(replay).resolves.toEqual({ status: "cancelled" }); - // Void calls are harmless no-ops after teardown. - handle.destroy(); - handle.hide(); - handle.refresh(); - handle.resketch(3); - expect(overlay()).toBeNull(); - expect(node.hasAttribute("aria-describedby")).toBe(false); - await vi.advanceTimersByTimeAsync(1000); - }); - it("creates no operation for zero duration or reduced motion", async () => { - vi.useFakeTimers(); - vi.stubGlobal("matchMedia", () => ({ matches: true })); - const reduced = attach(element(), { seed: 1, animate: true, animationDuration: 200 }); - expect(offset()).toBeNull(); - const first = reduced.show(); - const second = reduced.show(); - expect(first).not.toBe(second); - await expect(first).resolves.toEqual({ status: "finished" }); - reduced.destroy(); - document.body.replaceChildren(); + it("creates no operation for zero duration or reduced motion", async () => { + vi.useFakeTimers(); + vi.stubGlobal("matchMedia", () => ({ matches: true })); + const reduced = attach(element(), { seed: 1, animate: true, animationDuration: 200 }); + expect(offset()).toBeNull(); + const first = reduced.show(); + const second = reduced.show(); + expect(first).not.toBe(second); + await expect(first).resolves.toEqual({ status: "finished" }); + reduced.destroy(); + document.body.replaceChildren(); - vi.stubGlobal("matchMedia", () => ({ matches: false })); - const zero = attach(element(), { - seed: 1, - animate: true, - animationDuration: 0, - animationDelay: 500, + vi.stubGlobal("matchMedia", () => ({ matches: false })); + const zero = attach(element(), { + seed: 1, + animate: true, + animationDuration: 0, + animationDelay: 500, + }); + expect(offset()).toBeNull(); + zero.hide(); + await expect(zero.show()).resolves.toEqual({ status: "finished" }); + expect(overlay().hidden).toBe(false); + zero.destroy(); }); - expect(offset()).toBeNull(); - zero.hide(); - await expect(zero.show()).resolves.toEqual({ status: "finished" }); - expect(overlay().hidden).toBe(false); - zero.destroy(); - }); - it("keeps an initially hidden handle settled even under zero duration", () => { - vi.useFakeTimers(); - const handle = attach(element(), { - seed: 1, - visible: false, - animate: true, - animationDuration: 0, + it("keeps an initially hidden handle settled even under zero duration", () => { + vi.useFakeTimers(); + const handle = attach(element(), { + seed: 1, + visible: false, + animate: true, + animationDuration: 0, + }); + expect(overlay().hidden).toBe(true); + expect(offset()).toBeNull(); + handle.destroy(); }); - expect(overlay().hidden).toBe(true); - expect(offset()).toBeNull(); - handle.destroy(); - }); - it("settles an active operation finished when reduced motion becomes active", async () => { - vi.useFakeTimers(); - const media = reducedMotion(false); - vi.stubGlobal("matchMedia", () => media); - const handle = attach(element(), { seed: 1, animate: true, animationDuration: 400 }); - const op = handle.show(); - vi.advanceTimersByTime(100); - expect(offset()).not.toBeNull(); - media.set(true); - expect(offset()).toBeNull(); - await expect(op).resolves.toEqual({ status: "finished" }); - handle.destroy(); - }); -}); + it("settles an active operation finished when reduced motion becomes active", async () => { + vi.useFakeTimers(); + const media = reducedMotion(false); + vi.stubGlobal("matchMedia", () => media); + const handle = attach(element(), { seed: 1, animate: true, animationDuration: 400 }); + const op = handle.show(); + vi.advanceTimersByTime(100); + expect(offset()).not.toBeNull(); + media.set(true); + expect(offset()).toBeNull(); + await expect(op).resolves.toEqual({ status: "finished" }); + handle.destroy(); + }); + }, +); describe("visibility and ARIA ownership", () => { const overlay = () => document.querySelector(".stet-overlay"); @@ -972,7 +976,7 @@ it("agent capability defaults reproduce omitted runtime defaults", () => { ? mark(from, "wrong", options) : name === "arrow" ? arrow(from, to, options) - : ({ circle, underline, highlight, sticky } as any)[name](from, options); + : ({ box, circle, underline, highlight, sticky } as any)[name](from, options); const implicit = attach({ ...required, seed: 42 }); const snapshot = () => ({ paths: paths(), @@ -1034,173 +1038,186 @@ it("moves an arrow label independently of its path and retains viewport clamping // CORE-06: reveal composes with ambient boil, hover resketch, and live media // changes without replaying or changing an in-flight operation's deadline. -describe.each(["circle", "underline"] as const)("%s ambient motion composition", (primitive) => { - const attach = primitive === "circle" ? circle : underline; - const overlay = () => document.querySelector(`.stet-overlay--${primitive}`)!; - const drawn = () => [ - ...document.querySelectorAll(`.stet-overlay--${primitive} path`), - ]; - const first = () => drawn()[0]; - const offset = () => first().getAttribute("stroke-dashoffset"); - const reducedMotion = (initial: boolean) => { - let matches = initial; - const listeners = new Set<() => void>(); - return { - get matches() { - return matches; - }, - addEventListener: (_type: string, listener: () => void) => { - listeners.add(listener); - }, - removeEventListener: (_type: string, listener: () => void) => { - listeners.delete(listener); - }, - set(value: boolean) { - matches = value; - for (const listener of listeners) listener(); - }, +describe.each(["box", "circle", "underline"] as const)( + "%s ambient motion composition", + (primitive) => { + const attach = { box, circle, underline }[primitive]; + const overlay = () => document.querySelector(`.stet-overlay--${primitive}`)!; + const drawn = () => [ + ...document.querySelectorAll(`.stet-overlay--${primitive} path`), + ]; + const first = () => drawn()[0]; + const offset = () => first().getAttribute("stroke-dashoffset"); + const reducedMotion = (initial: boolean) => { + let matches = initial; + const listeners = new Set<() => void>(); + return { + get matches() { + return matches; + }, + addEventListener: (_type: string, listener: () => void) => { + listeners.add(listener); + }, + removeEventListener: (_type: string, listener: () => void) => { + listeners.delete(listener); + }, + set(value: boolean) { + matches = value; + for (const listener of listeners) listener(); + }, + }; }; - }; - it("settles finished at the final frame when reduced motion activates during the delay", async () => { - vi.useFakeTimers(); - const media = reducedMotion(false); - vi.stubGlobal("matchMedia", () => media); - const handle = attach(element(), { - seed: 1, - animate: true, - animationDelay: 200, - animationDuration: 400, + it("settles finished at the final frame when reduced motion activates during the delay", async () => { + vi.useFakeTimers(); + const media = reducedMotion(false); + vi.stubGlobal("matchMedia", () => media); + const handle = attach(element(), { + seed: 1, + animate: true, + animationDelay: 200, + animationDuration: 400, + }); + const op = handle.show(); + expect(offset()).toBe("1"); + media.set(true); + // The delay is skipped entirely and the final static frame has no reveal state. + expect(offset()).toBeNull(); + expect(first().hasAttribute("pathLength")).toBe(false); + expect(overlay().hidden).toBe(false); + await expect(op).resolves.toEqual({ status: "finished" }); + // The operation is settled, so a later show is a fresh no-replay result. + const settled = handle.show(); + expect(settled).not.toBe(op); + await expect(settled).resolves.toEqual({ status: "finished" }); + handle.destroy(); }); - const op = handle.show(); - expect(offset()).toBe("1"); - media.set(true); - // The delay is skipped entirely and the final static frame has no reveal state. - expect(offset()).toBeNull(); - expect(first().hasAttribute("pathLength")).toBe(false); - expect(overlay().hidden).toBe(false); - await expect(op).resolves.toEqual({ status: "finished" }); - // The operation is settled, so a later show is a fresh no-replay result. - const settled = handle.show(); - expect(settled).not.toBe(op); - await expect(settled).resolves.toEqual({ status: "finished" }); - handle.destroy(); - }); - it("settles finished and drops boil when reduced motion activates during the reveal", async () => { - vi.useFakeTimers(); - const media = reducedMotion(false); - vi.stubGlobal("matchMedia", () => media); - const handle = attach(element(), { seed: 1, animate: true, animationDuration: 400, boil: 0.4 }); - expect(drawn()).toHaveLength(3); - const op = handle.show(); - vi.advanceTimersByTime(100); - expect(Number(offset())).toBeLessThan(1); - media.set(true); - expect(offset()).toBeNull(); - // Reduced motion renders one static variant and touches no reveal attributes. - expect(drawn()).toHaveLength(1); - expect(drawn()[0].getAttribute("class")).not.toContain("stet-boil"); - await expect(op).resolves.toEqual({ status: "finished" }); - handle.destroy(); - }); + it("settles finished and drops boil when reduced motion activates during the reveal", async () => { + vi.useFakeTimers(); + const media = reducedMotion(false); + vi.stubGlobal("matchMedia", () => media); + const handle = attach(element(), { + seed: 1, + animate: true, + animationDuration: 400, + boil: 0.4, + }); + expect(drawn()).toHaveLength(3); + const op = handle.show(); + vi.advanceTimersByTime(100); + expect(Number(offset())).toBeLessThan(1); + media.set(true); + expect(offset()).toBeNull(); + // Reduced motion renders one static variant and touches no reveal attributes. + expect(drawn()).toHaveLength(1); + expect(drawn()[0].getAttribute("class")).not.toContain("stet-boil"); + await expect(op).resolves.toEqual({ status: "finished" }); + handle.destroy(); + }); - it("hover resketch redraws at current progress and keeps the operation deadline", async () => { - vi.useFakeTimers(); - const node = element(); - const handle = attach(node, { - seed: 1, - animate: true, - animationDuration: 600, - boil: 0.3, - resketchOnHover: true, + it("hover resketch redraws at current progress and keeps the operation deadline", async () => { + vi.useFakeTimers(); + const node = element(); + const handle = attach(node, { + seed: 1, + animate: true, + animationDuration: 600, + boil: 0.3, + resketchOnHover: true, + }); + const op = handle.show(); + vi.advanceTimersByTime(300); + const before = Number(offset()); + const beforeD = first().getAttribute("d"); + expect(before).toBeGreaterThan(0); + expect(before).toBeLessThan(1); + node.dispatchEvent(new PointerEvent("pointerenter")); + // The operation is untouched: same promise, and progress never jumps back. + expect(handle.show()).toBe(op); + expect(Number(offset())).toBeLessThanOrEqual(before); + expect(first().getAttribute("d")).not.toBe(beforeD); + // Only the remaining time is needed to settle, so the deadline is unchanged. + vi.advanceTimersByTime(310); + expect(offset()).toBeNull(); + await expect(op).resolves.toEqual({ status: "finished" }); + handle.destroy(); }); - const op = handle.show(); - vi.advanceTimersByTime(300); - const before = Number(offset()); - const beforeD = first().getAttribute("d"); - expect(before).toBeGreaterThan(0); - expect(before).toBeLessThan(1); - node.dispatchEvent(new PointerEvent("pointerenter")); - // The operation is untouched: same promise, and progress never jumps back. - expect(handle.show()).toBe(op); - expect(Number(offset())).toBeLessThanOrEqual(before); - expect(first().getAttribute("d")).not.toBe(beforeD); - // Only the remaining time is needed to settle, so the deadline is unchanged. - vi.advanceTimersByTime(310); - expect(offset()).toBeNull(); - await expect(op).resolves.toEqual({ status: "finished" }); - handle.destroy(); - }); - it("keeps every boil variant hidden at frame zero and clear at the final frame", () => { - vi.useFakeTimers(); - const handle = attach(element(), { seed: 1, animate: true, animationDuration: 300, boil: 0.5 }); - const variants = drawn(); - expect(variants).toHaveLength(3); - for (const variant of variants) { - expect(variant.getAttribute("class")).toContain("stet-boil"); - expect(variant.getAttribute("pathLength")).toBe("1"); - expect(variant.getAttribute("stroke-dasharray")).toBe("1"); - expect(variant.getAttribute("stroke-dashoffset")).toBe("1"); - expect(variant.hasAttribute("opacity")).toBe(false); - } - vi.advanceTimersByTime(400); - for (const variant of variants) { - expect(variant.getAttribute("pathLength")).toBeNull(); - expect(variant.getAttribute("stroke-dasharray")).toBeNull(); - expect(variant.getAttribute("stroke-dashoffset")).toBeNull(); - expect(variant.hasAttribute("opacity")).toBe(false); - } - handle.destroy(); - }); + it("keeps every boil variant hidden at frame zero and clear at the final frame", () => { + vi.useFakeTimers(); + const handle = attach(element(), { + seed: 1, + animate: true, + animationDuration: 300, + boil: 0.5, + }); + const variants = drawn(); + expect(variants).toHaveLength(3); + for (const variant of variants) { + expect(variant.getAttribute("class")).toContain("stet-boil"); + expect(variant.getAttribute("pathLength")).toBe("1"); + expect(variant.getAttribute("stroke-dasharray")).toBe("1"); + expect(variant.getAttribute("stroke-dashoffset")).toBe("1"); + expect(variant.hasAttribute("opacity")).toBe(false); + } + vi.advanceTimersByTime(400); + for (const variant of variants) { + expect(variant.getAttribute("pathLength")).toBeNull(); + expect(variant.getAttribute("stroke-dasharray")).toBeNull(); + expect(variant.getAttribute("stroke-dashoffset")).toBeNull(); + expect(variant.hasAttribute("opacity")).toBe(false); + } + handle.destroy(); + }); - it("reduced motion yields one stable variant and disables hover resketch", () => { - vi.useFakeTimers(); - const media = reducedMotion(true); - vi.stubGlobal("matchMedia", () => media); - const node = element(); - const handle = attach(node, { seed: 1, boil: 0.5, resketchOnHover: true }); - expect(drawn()).toHaveLength(1); - expect(drawn()[0].getAttribute("class")).not.toContain("stet-boil"); - const stable = paths(); - node.dispatchEvent(new PointerEvent("pointerenter")); - expect(drawn()).toHaveLength(1); - expect(paths()).toEqual(stable); - // A repeat refresh is still the same stable single variant for capture. - handle.refresh(); - expect(paths()).toEqual(stable); - handle.destroy(); - }); + it("reduced motion yields one stable variant and disables hover resketch", () => { + vi.useFakeTimers(); + const media = reducedMotion(true); + vi.stubGlobal("matchMedia", () => media); + const node = element(); + const handle = attach(node, { seed: 1, boil: 0.5, resketchOnHover: true }); + expect(drawn()).toHaveLength(1); + expect(drawn()[0].getAttribute("class")).not.toContain("stet-boil"); + const stable = paths(); + node.dispatchEvent(new PointerEvent("pointerenter")); + expect(drawn()).toHaveLength(1); + expect(paths()).toEqual(stable); + // A repeat refresh is still the same stable single variant for capture. + handle.refresh(); + expect(paths()).toEqual(stable); + handle.destroy(); + }); - it("settles an offscreen operation finished when reduced motion activates while culled", async () => { - vi.useFakeTimers(); - const node = element(20, 1400); - const media = reducedMotion(false); - vi.stubGlobal("matchMedia", () => media); - const handle = attach(node, { seed: 1, animate: true, animationDuration: 600 }); - expect(overlay().hidden).toBe(true); - const op = handle.show(); - vi.advanceTimersByTime(200); - media.set(true); - await expect(op).resolves.toEqual({ status: "finished" }); - expect(offset()).toBeNull(); - // Reconnecting shows the final frame rather than replaying. - const onscreen = new DOMRect(10, 20, 100, 30); - vi.mocked(node.getBoundingClientRect).mockReturnValue(onscreen); - vi.mocked(node.getClientRects).mockReturnValue([onscreen] as unknown as DOMRectList); - handle.refresh(); - expect(overlay().hidden).toBe(false); - expect(offset()).toBeNull(); - handle.destroy(); - }); -}); + it("settles an offscreen operation finished when reduced motion activates while culled", async () => { + vi.useFakeTimers(); + const node = element(20, 1400); + const media = reducedMotion(false); + vi.stubGlobal("matchMedia", () => media); + const handle = attach(node, { seed: 1, animate: true, animationDuration: 600 }); + expect(overlay().hidden).toBe(true); + const op = handle.show(); + vi.advanceTimersByTime(200); + media.set(true); + await expect(op).resolves.toEqual({ status: "finished" }); + expect(offset()).toBeNull(); + // Reconnecting shows the final frame rather than replaying. + const onscreen = new DOMRect(10, 20, 100, 30); + vi.mocked(node.getBoundingClientRect).mockReturnValue(onscreen); + vi.mocked(node.getClientRects).mockReturnValue([onscreen] as unknown as DOMRectList); + handle.refresh(); + expect(overlay().hidden).toBe(false); + expect(offset()).toBeNull(); + handle.destroy(); + }); + }, +); // CORE-07: adapters update supported runtime data through the source-only // nonreplaying path. Every field must apply in place without touching the // current operation, the current seed, or explicit visibility. describe("updateHandle", () => { - const names = ["circle", "underline", "highlight", "mark", "sticky", "arrow"] as const; + const names = ["box", "circle", "underline", "highlight", "mark", "sticky", "arrow"] as const; type Name = (typeof names)[number]; const overlay = () => document.querySelector(".stet-overlay")!; const path = () => document.querySelector(".stet-svg path")!; @@ -1217,6 +1234,8 @@ describe("updateHandle", () => { function attachHandle(name: Name, node: Element, to: Element): StetHandle { switch (name) { + case "box": + return box(node, { seed: 1 }); case "circle": return circle(node, { seed: 1 }); case "underline": @@ -1313,7 +1332,7 @@ describe("updateHandle", () => { handle.destroy(); }); - it.each(["circle", "underline", "highlight", "mark"] as const)( + it.each(["box", "circle", "underline", "highlight", "mark"] as const)( "updates padding for %s", (name) => { const node = element(); diff --git a/tests/svelte.test.ts b/tests/svelte.test.ts index 6493991..412b5e9 100644 --- a/tests/svelte.test.ts +++ b/tests/svelte.test.ts @@ -1,11 +1,49 @@ -import { afterEach, expect, it, vi } from "vitest"; -import { circle } from "../src/svelte.js"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { arrow, circle, mark, sticky } from "../src/svelte.js"; +import type { StetHandle } from "../src/index.js"; + +type Callback = (handle: StetHandle | null) => void; + +/** + * happy-dom returns zero-sized rects, which the runtime treats as culled. Pin a + * fixed onscreen box so overlay `hidden` reflects explicit visibility, not + * geometry. + */ +const RECT = new DOMRect(10, 20, 100, 30); + +beforeEach(() => { + vi.stubGlobal("innerWidth", 1024); + vi.stubGlobal("innerHeight", 768); + vi.stubGlobal("matchMedia", () => ({ matches: false })); + vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue(RECT); + vi.spyOn(Element.prototype, "getClientRects").mockReturnValue([RECT] as unknown as DOMRectList); +}); afterEach(() => { document.body.textContent = ""; + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); +function collector(): { calls: (StetHandle | null)[]; onHandle: Callback } { + const calls: (StetHandle | null)[] = []; + return { calls, onHandle: (handle) => calls.push(handle) }; +} + +function host(): HTMLButtonElement { + const element = document.createElement("button"); + document.body.append(element); + return element; +} + +function overlay(): HTMLElement { + return document.querySelector(".stet-overlay")!; +} + +function ink(): string { + return overlay().style.getPropertyValue("--stet-ink"); +} + it("keeps an action mounted when option values do not change", () => { vi.stubGlobal( "ResizeObserver", @@ -31,7 +69,9 @@ it("observes an options object edited in place", () => { const action = circle(element, options); options.stroke = "blue"; action.update(options); - expect(document.querySelector(".stet-overlay")?.style.getPropertyValue("--stet-ink")).toBe("blue"); + expect( + document.querySelector(".stet-overlay")?.style.getPropertyValue("--stet-ink"), + ).toBe("blue"); action.destroy(); }); @@ -42,3 +82,266 @@ it("allows simple actions without an options argument", () => { expect(document.querySelector(".stet-overlay--circle")).not.toBeNull(); action.destroy(); }); + +it("delivers the handle after attachment and null on destroy", () => { + const { calls, onHandle } = collector(); + const element = host(); + const action = circle(element, { seed: 1, onHandle }); + + expect(calls).toHaveLength(1); + const handle = calls[0]!; + expect(handle).not.toBeNull(); + + action.destroy(); + expect(calls).toEqual([handle, null]); + expect(document.querySelector(".stet-overlay")).toBeNull(); +}); + +it("transfers a live handle to a new callback without remount, update, or replay", () => { + const a = collector(); + const b = collector(); + const element = host(); + const action = circle(element, { seed: 1, onHandle: a.onHandle }); + + const handle = a.calls[0]!; + const mounted = overlay(); + const replay = vi.spyOn(handle, "replay"); + const show = vi.spyOn(handle, "show"); + const hide = vi.spyOn(handle, "hide"); + const destroy = vi.spyOn(handle, "destroy"); + + action.update({ seed: 1, onHandle: b.onHandle }); + expect(a.calls).toEqual([handle, null]); + expect(b.calls).toEqual([handle]); + expect(overlay()).toBe(mounted); + expect(replay).not.toHaveBeenCalled(); + expect(show).not.toHaveBeenCalled(); + expect(hide).not.toHaveBeenCalled(); + expect(destroy).not.toHaveBeenCalled(); + + // The new owner, not the old one, receives the teardown null. + action.destroy(); + expect(b.calls).toEqual([handle, null]); +}); + +it("still delivers the existing handle when the outgoing callback throws", () => { + const seen: (StetHandle | null)[] = []; + const oldCallback: Callback = (handle) => { + if (handle === null) throw new Error("old callback failed"); + seen.push(handle); + }; + const next = collector(); + const element = host(); + const action = circle(element, { seed: 1, onHandle: oldCallback }); + const handle = seen[0]!; + + // The outgoing failure must not stop the new owner from receiving the handle. + expect(() => action.update({ seed: 1, onHandle: next.onHandle })).toThrow("old callback failed"); + expect(next.calls).toEqual([handle]); + + action.destroy(); + expect(next.calls).toEqual([handle, null]); +}); + +it("applies a changed visible value once and preserves imperative state", () => { + const { calls, onHandle } = collector(); + const element = host(); + const base = { seed: 1, onHandle }; + const action = circle(element, { ...base, visible: false, stroke: "red" }); + + expect(overlay().hidden).toBe(true); + const handle = calls[0]!; + const show = vi.spyOn(handle, "show"); + const hide = vi.spyOn(handle, "hide"); + + // An unrelated update while hidden must not call show or hide. + action.update({ ...base, visible: false, stroke: "blue" }); + expect(show).not.toHaveBeenCalled(); + expect(hide).not.toHaveBeenCalled(); + expect(overlay().hidden).toBe(true); + + action.update({ ...base, visible: true, stroke: "blue" }); + expect(show).toHaveBeenCalledTimes(1); + expect(overlay().hidden).toBe(false); + + handle.hide(); + expect(hide).toHaveBeenCalledTimes(1); + expect(overlay().hidden).toBe(true); + + // An unrelated update must not overwrite the imperative hide. + const showBefore = show.mock.calls.length; + action.update({ ...base, visible: true, stroke: "green" }); + expect(hide).toHaveBeenCalledTimes(1); + expect(show).toHaveBeenCalledTimes(showBefore); + expect(overlay().hidden).toBe(true); + + action.update({ ...base, visible: false, stroke: "green" }); + expect(hide).toHaveBeenCalledTimes(2); + + // Explicit false -> omitted is a change to the default true. + action.update({ ...base, visible: undefined, stroke: "green" }); + expect(show).toHaveBeenCalledTimes(2); + expect(overlay().hidden).toBe(false); + + action.destroy(); +}); + +it("updates non-visible options in place through updateHandle without remount or replay", () => { + const { calls, onHandle } = collector(); + const element = host(); + const action = circle(element, { seed: 1, stroke: "red", onHandle }); + + const handle = calls[0]!; + const mounted = overlay(); + const drawing = document.querySelector(".stet-svg path")!.getAttribute("d"); + + action.update({ seed: 1, stroke: "blue", description: "Explained", onHandle }); + expect(calls).toEqual([handle]); + expect(overlay()).toBe(mounted); + expect(ink()).toBe("blue"); + expect(document.querySelector(".stet-description")?.textContent).toBe("Explained"); + expect(document.querySelector(".stet-svg path")!.getAttribute("d")).toBe(drawing); + expect(element.getAttribute("aria-describedby")).toContain("stet-description-"); + + action.destroy(); +}); + +it("sends a complete snapshot so removed values return to defaults", () => { + const { calls, onHandle } = collector(); + const element = host(); + const action = circle(element, { seed: 1, padding: 20, description: "A", onHandle }); + + const handle = calls[0]!; + const wide = parseFloat(overlay().style.width); + expect(document.querySelector(".stet-description")).not.toBeNull(); + + action.update({ seed: 1, padding: undefined, description: undefined, onHandle }); + expect(parseFloat(overlay().style.width)).toBeLessThan(wide); + expect(document.querySelector(".stet-description")).toBeNull(); + expect(calls).toEqual([handle]); + + action.destroy(); +}); + +it("waits silently for an absent Arrow destination and reports null when it disappears", () => { + const { calls, onHandle } = collector(); + const element = host(); + const destination = document.createElement("p"); + document.body.append(destination); + const action = arrow(element, { to: undefined, seed: 1, onHandle }); + + // No attach and no null notification while the destination is absent. + expect(calls).toHaveLength(0); + expect(document.querySelector(".stet-overlay")).toBeNull(); + + action.update({ to: destination, seed: 1, onHandle }); + expect(calls).toHaveLength(1); + const handle = calls[0]!; + expect(handle).not.toBeNull(); + + action.update({ to: null, seed: 1, onHandle }); + expect(calls).toEqual([handle, null]); + expect(document.querySelector(".stet-overlay")).toBeNull(); + + // A reappearing destination attaches a fresh handle. + action.update({ to: destination, seed: 1, onHandle }); + expect(calls).toHaveLength(3); + expect(calls[2]).not.toBe(handle); + + action.destroy(); +}); + +it("replaces the handle when Arrow's destination changes and moves owned ARIA", () => { + const { calls, onHandle } = collector(); + const element = host(); + const first = document.createElement("p"); + const second = document.createElement("p"); + document.body.append(first, second); + const action = arrow(element, { to: first, seed: 1, label: "One", onHandle }); + + const handle = calls[0]!; + const firstOverlay = overlay(); + expect(document.querySelector(".stet-label")?.textContent).toBe("One"); + expect(first.getAttribute("aria-describedby")).toContain("stet-description-"); + + // A label change updates in place; the handle is not recreated. + action.update({ to: first, seed: 1, label: "Two", onHandle }); + expect(document.querySelector(".stet-label")?.textContent).toBe("Two"); + expect(calls).toEqual([handle]); + + action.update({ to: second, seed: 1, label: "Two", onHandle }); + expect(calls[0]).toBe(handle); + expect(calls[1]).toBeNull(); + expect(calls[2]).not.toBe(handle); + expect(calls).toHaveLength(3); + // The old overlay and its owned ARIA relationship are torn down; the + // replacement attaches to the new destination. + expect(overlay()).not.toBe(firstOverlay); + expect(first.hasAttribute("aria-describedby")).toBe(false); + expect(second.getAttribute("aria-describedby")).toContain("stet-description-"); + + action.destroy(); +}); + +it("attaches the Arrow replacement even when the outgoing owner throws on null", () => { + const seen: StetHandle[] = []; + const outgoing: Callback = (handle) => { + if (handle === null) throw new Error("outgoing failed"); + seen.push(handle); + }; + const next = collector(); + const element = host(); + const first = document.createElement("p"); + const second = document.createElement("p"); + document.body.append(first, second); + const action = arrow(element, { to: first, seed: 1, label: "L", onHandle: outgoing }); + const handle = seen[0]!; + const firstOverlay = overlay(); + + // The outgoing exception is surfaced, not swallowed... + expect(() => action.update({ to: second, seed: 1, label: "L", onHandle: next.onHandle })).toThrow( + "outgoing failed", + ); + expect(seen).toEqual([handle]); + // ...and the valid replacement still attached and reached the new owner. + expect(next.calls).toHaveLength(1); + const replacement = next.calls[0]!; + expect(replacement).not.toBe(handle); + expect(overlay()).not.toBe(firstOverlay); + expect(first.hasAttribute("aria-describedby")).toBe(false); + + // The new owner receives the teardown null. + action.destroy(); + expect(next.calls).toEqual([replacement, null]); +}); + +it("updates mark kind in place without remount", () => { + const { calls, onHandle } = collector(); + const element = host(); + const action = mark(element, { kind: "right", seed: 1, onHandle }); + + const handle = calls[0]!; + expect(document.querySelector(".stet-mark--right")).not.toBeNull(); + + action.update({ kind: "wrong", seed: 1, onHandle }); + expect(document.querySelector(".stet-mark--wrong")).not.toBeNull(); + expect(document.querySelector(".stet-mark--right")).toBeNull(); + expect(calls).toEqual([handle]); + + action.destroy(); +}); + +it("updates sticky text in place without remount", () => { + const { calls, onHandle } = collector(); + const element = host(); + const action = sticky(element, { text: "One", seed: 1, onHandle }); + + const handle = calls[0]!; + expect(document.querySelector(".stet-sticky-text")?.textContent).toBe("One"); + + action.update({ text: "Two", seed: 1, onHandle }); + expect(document.querySelector(".stet-sticky-text")?.textContent).toBe("Two"); + expect(calls).toEqual([handle]); + + action.destroy(); +}); diff --git a/tests/vue.test.ts b/tests/vue.test.ts index 1b621e0..c8e158c 100644 --- a/tests/vue.test.ts +++ b/tests/vue.test.ts @@ -1,20 +1,436 @@ -import { createApp, h, nextTick, reactive, withDirectives } from "vue"; -import { expect, it } from "vitest"; -import { vStetCircle } from "../src/vue.js"; +import { createApp, h, nextTick, reactive, withDirectives, type App } from "vue"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { vStetArrow, vStetCircle, vStetMark, vStetSticky } from "../src/vue.js"; +import type { MarkKind, StetHandle } from "../src/index.js"; -it("tracks an edited reactive options object and destroys the directive", async () => { - const options = reactive({ stroke: "red", seed: 7 }); +type Callback = (handle: StetHandle | null) => void; + +/** + * happy-dom returns zero-sized rects, which the runtime treats as culled. Pin a + * fixed onscreen box so overlay `hidden` reflects explicit visibility, not + * geometry. + */ +const RECT = new DOMRect(10, 20, 100, 30); + +beforeEach(() => { + vi.stubGlobal("innerWidth", 1024); + vi.stubGlobal("innerHeight", 768); + vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue(RECT); + vi.spyOn(Element.prototype, "getClientRects").mockReturnValue([RECT] as unknown as DOMRectList); +}); + +afterEach(() => { + document.body.textContent = ""; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function collector(): { calls: (StetHandle | null)[]; onHandle: Callback } { + const calls: (StetHandle | null)[] = []; + return { calls, onHandle: (handle) => calls.push(handle) }; +} + +function mount(render: () => ReturnType): { app: App; root: HTMLElement } { const root = document.createElement("div"); document.body.append(root); - const app = createApp({ render: () => withDirectives(h("button", "Save"), [[vStetCircle, options]]) }); + const app = createApp({ render }); app.mount(root); - const before = document.querySelector(".stet-overlay"); + return { app, root }; +} + +function overlay(): HTMLElement { + return document.querySelector(".stet-overlay")!; +} + +function ink(): string { + return overlay().style.getPropertyValue("--stet-ink"); +} + +it("delivers the handle after attachment and null on unmount", () => { + const { calls, onHandle } = collector(); + const { app, root } = mount(() => + withDirectives(h("button", "Save"), [[vStetCircle, { onHandle }]]), + ); + + expect(calls).toHaveLength(1); + const handle = calls[0]!; + expect(handle).not.toBeNull(); + app.unmount(); + expect(calls).toEqual([handle, null]); + expect(document.querySelector(".stet-overlay")).toBeNull(); + root.remove(); +}); + +it("tracks an edited reactive options object in place and destroys the directive", async () => { + const options = reactive({ stroke: "red", seed: 7 }); + const { app, root } = mount(() => withDirectives(h("button", "Save"), [[vStetCircle, options]])); + const before = overlay(); + expect(ink()).toBe("red"); + options.stroke = "purple"; await nextTick(); - const after = document.querySelector(".stet-overlay"); - expect(after).not.toBe(before); - expect(after?.style.getPropertyValue("--stet-ink")).toBe("purple"); + const after = overlay(); + // A non-visible option change updates through updateHandle, not a remount. + expect(after).toBe(before); + expect(ink()).toBe("purple"); + + app.unmount(); + expect(document.querySelector(".stet-overlay")).toBeNull(); + root.remove(); +}); + +it("applies a changed visible value once and preserves imperative state", async () => { + const { calls, onHandle } = collector(); + const state = reactive<{ visible?: boolean; stroke: string }>({ visible: false, stroke: "red" }); + const { app, root } = mount(() => + withDirectives(h("button", "Save"), [ + [vStetCircle, { visible: state.visible, stroke: state.stroke, seed: 1, onHandle }], + ]), + ); + + expect(overlay().hidden).toBe(true); + const handle = calls[0]!; + const show = vi.spyOn(handle, "show"); + const hide = vi.spyOn(handle, "hide"); + + // An unrelated update while hidden must not call show or hide. + state.stroke = "blue"; + await nextTick(); + expect(show).not.toHaveBeenCalled(); + expect(hide).not.toHaveBeenCalled(); + expect(overlay().hidden).toBe(true); + + state.visible = true; + await nextTick(); + expect(show).toHaveBeenCalledTimes(1); + expect(overlay().hidden).toBe(false); + + handle.hide(); + expect(hide).toHaveBeenCalledTimes(1); + expect(overlay().hidden).toBe(true); + + // An unrelated update must not overwrite the imperative hide. + const showBefore = show.mock.calls.length; + state.stroke = "green"; + await nextTick(); + expect(hide).toHaveBeenCalledTimes(1); + expect(show).toHaveBeenCalledTimes(showBefore); + expect(overlay().hidden).toBe(true); + + state.visible = false; + await nextTick(); + expect(hide).toHaveBeenCalledTimes(2); + + // Explicit false -> omitted is a change to the default true. + state.visible = undefined; + await nextTick(); + expect(show).toHaveBeenCalledTimes(2); + expect(overlay().hidden).toBe(false); + + app.unmount(); + root.remove(); +}); + +it("updates non-visible options through updateHandle without remount or replay", async () => { + const { calls, onHandle } = collector(); + const state = reactive<{ stroke: string; description?: string }>({ stroke: "red" }); + const { app, root } = mount(() => + withDirectives(h("button", "Save"), [[vStetCircle, { ...state, seed: 1, onHandle }]]), + ); + + const handle = calls[0]!; + const element = overlay(); + const drawing = document.querySelector(".stet-svg path")!.getAttribute("d"); + + state.stroke = "blue"; + state.description = "Explained"; + await nextTick(); + expect(calls).toEqual([handle]); + expect(overlay()).toBe(element); + expect(ink()).toBe("blue"); + expect(document.querySelector(".stet-description")?.textContent).toBe("Explained"); + expect(document.querySelector(".stet-svg path")!.getAttribute("d")).toBe(drawing); + expect(document.querySelector("button")!.getAttribute("aria-describedby")).toContain( + "stet-description-", + ); + + app.unmount(); + root.remove(); +}); + +it("sends a complete snapshot so removed values return to defaults", async () => { + const { calls, onHandle } = collector(); + const state = reactive<{ padding?: number; description?: string }>({ + padding: 20, + description: "A", + }); + const { app, root } = mount(() => + withDirectives(h("button", "Save"), [[vStetCircle, { ...state, seed: 1, onHandle }]]), + ); + + const handle = calls[0]!; + const wide = parseFloat(overlay().style.width); + expect(document.querySelector(".stet-description")).not.toBeNull(); + + state.padding = undefined; + state.description = undefined; + await nextTick(); + expect(parseFloat(overlay().style.width)).toBeLessThan(wide); + expect(document.querySelector(".stet-description")).toBeNull(); + expect(calls).toEqual([handle]); + + app.unmount(); + root.remove(); +}); + +it("transfers a live handle to a new callback without remount, update, or replay", async () => { + const a = collector(); + const b = collector(); + const state = reactive<{ onHandle: Callback }>({ onHandle: a.onHandle }); + const { app, root } = mount(() => + withDirectives(h("button", "Save"), [[vStetCircle, { seed: 1, onHandle: state.onHandle }]]), + ); + + const handle = a.calls[0]!; + const element = overlay(); + const replay = vi.spyOn(handle, "replay"); + const destroy = vi.spyOn(handle, "destroy"); + + state.onHandle = b.onHandle; + await nextTick(); + expect(a.calls).toEqual([handle, null]); + expect(b.calls).toEqual([handle]); + expect(overlay()).toBe(element); + expect(replay).not.toHaveBeenCalled(); + expect(destroy).not.toHaveBeenCalled(); + + // The new owner, not the old one, receives the teardown null. + app.unmount(); + expect(b.calls).toEqual([handle, null]); + root.remove(); +}); + +it("replaces the handle when Arrow's destination changes", async () => { + const { calls, onHandle } = collector(); + const first = document.createElement("p"); + const second = document.createElement("p"); + document.body.append(first, second); + const state = reactive<{ to: Element; label: string }>({ to: first, label: "One" }); + const { app, root } = mount(() => + withDirectives(h("button", "From"), [[vStetArrow, { ...state, seed: 1, onHandle }]]), + ); + + const handle = calls[0]!; + const firstOverlay = overlay(); + expect(document.querySelector(".stet-label")?.textContent).toBe("One"); + expect(first.getAttribute("aria-describedby")).toContain("stet-description-"); + + state.label = "Two"; + await nextTick(); + expect(document.querySelector(".stet-label")?.textContent).toBe("Two"); + expect(calls).toEqual([handle]); + + state.to = second; + await nextTick(); + expect(calls[0]).toBe(handle); + expect(calls[1]).toBeNull(); + expect(calls[2]).not.toBe(handle); + expect(calls).toHaveLength(3); + // The old overlay and its owned ARIA relationship are torn down; the + // replacement attaches to the new destination. + expect(overlay()).not.toBe(firstOverlay); + expect(first.hasAttribute("aria-describedby")).toBe(false); + expect(second.getAttribute("aria-describedby")).toContain("stet-description-"); + app.unmount(); + root.remove(); +}); + +it("waits silently for an absent Arrow destination and reports null when it disappears", async () => { + const { calls, onHandle } = collector(); + const destination = document.createElement("p"); + document.body.append(destination); + const state = reactive<{ to?: Element | null }>({ to: undefined }); + const { app, root } = mount(() => + withDirectives(h("button", "From"), [[vStetArrow, { to: state.to, seed: 1, onHandle }]]), + ); + + // No attach and no null notification while the destination is absent. + expect(calls).toHaveLength(0); expect(document.querySelector(".stet-overlay")).toBeNull(); + + state.to = destination; + await nextTick(); + expect(calls).toHaveLength(1); + const handle = calls[0]!; + expect(handle).not.toBeNull(); + + state.to = null; + await nextTick(); + expect(calls).toEqual([handle, null]); + expect(document.querySelector(".stet-overlay")).toBeNull(); + + // A reappearing destination attaches a fresh handle. + state.to = destination; + await nextTick(); + expect(calls).toHaveLength(3); + expect(calls[2]).not.toBe(handle); + + app.unmount(); root.remove(); }); + +it("reports null and reattaches when the directive host element is replaced", async () => { + const { calls, onHandle } = collector(); + const state = reactive<{ tag: "button" | "a" }>({ tag: "button" }); + const { app, root } = mount(() => + withDirectives(h(state.tag, "Save"), [[vStetCircle, { seed: 1, description: "D", onHandle }]]), + ); + + const first = calls[0]!; + const oldNode = document.querySelector("button")!; + const firstOverlay = overlay(); + expect(oldNode.getAttribute("aria-describedby")).toContain("stet-description-"); + + // An imperative hide must not carry to the replacement; the new handle uses + // the current declarative visibility (default true). + first.hide(); + expect(overlay().hidden).toBe(true); + + state.tag = "a"; + await nextTick(); + expect(calls[0]).toBe(first); + expect(calls[1]).toBeNull(); + expect(calls[2]).not.toBe(first); + expect(calls).toHaveLength(3); + expect(overlay()).not.toBe(firstOverlay); + expect(overlay().hidden).toBe(false); + + // The old host's owned ARIA relationship is gone; the new one is live. + expect(oldNode.hasAttribute("aria-describedby")).toBe(false); + expect(document.querySelector("a")!.getAttribute("aria-describedby")).toContain( + "stet-description-", + ); + + app.unmount(); + root.remove(); +}); + +it("attaches the Arrow replacement even when the outgoing owner throws on null", async () => { + const seen: StetHandle[] = []; + const outgoing: Callback = (handle) => { + if (handle === null) throw new Error("outgoing failed"); + seen.push(handle); + }; + const next = collector(); + const first = document.createElement("p"); + const second = document.createElement("p"); + document.body.append(first, second); + const state = reactive<{ to: Element; onHandle: Callback }>({ to: first, onHandle: outgoing }); + const root = document.createElement("div"); + document.body.append(root); + const app = createApp({ + render: () => + withDirectives(h("button", "From"), [ + [vStetArrow, { to: state.to, seed: 1, label: "L", onHandle: state.onHandle }], + ]), + }); + const errors: unknown[] = []; + app.config.errorHandler = (error) => { + errors.push(error); + }; + app.mount(root); + const handle = seen[0]!; + const firstOverlay = overlay(); + + state.to = second; + state.onHandle = next.onHandle; + await nextTick(); + // The outgoing exception is surfaced, not swallowed... + expect(errors).toHaveLength(1); + expect((errors[0] as Error).message).toBe("outgoing failed"); + expect(seen).toEqual([handle]); + // ...and the valid replacement still attached and reached the new owner. + expect(next.calls).toHaveLength(1); + const replacement = next.calls[0]!; + expect(replacement).not.toBe(handle); + expect(overlay()).not.toBe(firstOverlay); + expect(first.hasAttribute("aria-describedby")).toBe(false); + + // The new owner receives the teardown null. + app.unmount(); + expect(next.calls).toEqual([replacement, null]); + root.remove(); +}); + +it("still delivers the existing handle when the outgoing callback throws", async () => { + const seen: (StetHandle | null)[] = []; + const oldCallback: Callback = (handle) => { + if (handle === null) throw new Error("old callback failed"); + seen.push(handle); + }; + const next = collector(); + const state = reactive<{ onHandle: Callback }>({ onHandle: oldCallback }); + const root = document.createElement("div"); + document.body.append(root); + const app = createApp({ + render: () => + withDirectives(h("button", "Save"), [[vStetCircle, { seed: 1, onHandle: state.onHandle }]]), + }); + const errors: unknown[] = []; + // Vue catches directive hook errors and routes them here; the adapter must + // neither swallow nor duplicate the delivery. + app.config.errorHandler = (error) => { + errors.push(error); + }; + app.mount(root); + const handle = seen[0]!; + + state.onHandle = next.onHandle; + await nextTick(); + expect(errors).toHaveLength(1); + expect((errors[0] as Error).message).toBe("old callback failed"); + // The outgoing failure does not stop the new owner from receiving the handle. + expect(next.calls).toEqual([handle]); + + app.unmount(); + expect(next.calls).toEqual([handle, null]); + root.remove(); +}); + +it("updates primitive-specific options in place: mark kind and sticky text", async () => { + const mark = reactive<{ kind: MarkKind }>({ kind: "right" }); + const markCollector = collector(); + const markApp = mount(() => + withDirectives(h("button", "Save"), [ + [vStetMark, { kind: mark.kind, seed: 1, onHandle: markCollector.onHandle }], + ]), + ); + const markHandle = markCollector.calls[0]!; + expect(document.querySelector(".stet-mark--right")).not.toBeNull(); + + mark.kind = "wrong"; + await nextTick(); + expect(document.querySelector(".stet-mark--wrong")).not.toBeNull(); + expect(document.querySelector(".stet-mark--right")).toBeNull(); + expect(markCollector.calls).toEqual([markHandle]); + markApp.app.unmount(); + markApp.root.remove(); + + const sticky = reactive<{ text: string }>({ text: "One" }); + const stickyCollector = collector(); + const stickyApp = mount(() => + withDirectives(h("button", "Save"), [ + [vStetSticky, { text: sticky.text, seed: 1, onHandle: stickyCollector.onHandle }], + ]), + ); + const stickyHandle = stickyCollector.calls[0]!; + expect(document.querySelector(".stet-sticky-text")?.textContent).toBe("One"); + + sticky.text = "Two"; + await nextTick(); + expect(document.querySelector(".stet-sticky-text")?.textContent).toBe("Two"); + expect(stickyCollector.calls).toEqual([stickyHandle]); + stickyApp.app.unmount(); + stickyApp.root.remove(); +}); From 85896c983ae50e2e2777f1dbe38c9ca74db45d57 Mon Sep 17 00:00:00 2001 From: Sai Nimmagadda Date: Sun, 13 Sep 2026 14:30:02 -0400 Subject: [PATCH 07/11] feat: complete release handoff and public proof --- docs/README.md | 1 + docs/delivery/backlog.md | 16 +-- docs/delivery/evidence/DOCS-01.md | 11 ++ docs/delivery/evidence/HANDOFF-01.md | 11 ++ docs/delivery/evidence/RELEASE-01.md | 12 ++ docs/delivery/evidence/WEB-01.md | 11 ++ docs/delivery/evidence/WEB-02.md | 11 ++ docs/delivery/tasks/DOCS-01.md | 2 +- docs/delivery/tasks/HANDOFF-01.md | 2 +- docs/delivery/tasks/WEB-01.md | 2 +- docs/delivery/tasks/WEB-02.md | 2 +- docs/reference.md | 56 +++++++- docs/rough-notation-compatibility.md | 137 +++++++++++++++++++ examples/handoff/README.md | 39 ++++++ examples/handoff/annotation-plan.json | 24 ++++ examples/handoff/app.js | 13 ++ examples/handoff/index.html | 45 +++++++ examples/handoff/source.js | 10 ++ examples/handoff/style.css | 14 ++ playwright.handoff.config.ts | 19 +++ tests/browser/visual.spec.ts | 3 +- tests/docs/rough-notation-compatibility.ts | 18 +++ tests/docs/tsconfig.json | 15 +++ tests/handoff/global-setup.ts | 13 ++ tests/handoff/handoff.spec.ts | 138 +++++++++++++++++++ website/src/components/HeroDemo.tsx | 149 +++++++++++---------- website/src/constants.ts | 29 ++++ website/src/pages/AgentWorkflow.tsx | 10 +- website/src/pages/Docs.tsx | 86 ++++++++---- website/src/pages/Home.tsx | 109 ++++++++++----- website/src/styles.css | 55 +++++++- website/tests/homepage.spec.ts | 127 ++++++++++++++++-- website/tests/sketchbook.spec.ts | 22 ++- 33 files changed, 1045 insertions(+), 167 deletions(-) create mode 100644 docs/delivery/evidence/DOCS-01.md create mode 100644 docs/delivery/evidence/HANDOFF-01.md create mode 100644 docs/delivery/evidence/RELEASE-01.md create mode 100644 docs/delivery/evidence/WEB-01.md create mode 100644 docs/delivery/evidence/WEB-02.md create mode 100644 docs/rough-notation-compatibility.md create mode 100644 examples/handoff/README.md create mode 100644 examples/handoff/annotation-plan.json create mode 100644 examples/handoff/app.js create mode 100644 examples/handoff/index.html create mode 100644 examples/handoff/source.js create mode 100644 examples/handoff/style.css create mode 100644 playwright.handoff.config.ts create mode 100644 tests/docs/rough-notation-compatibility.ts create mode 100644 tests/docs/tsconfig.json create mode 100644 tests/handoff/global-setup.ts create mode 100644 tests/handoff/handoff.spec.ts diff --git a/docs/README.md b/docs/README.md index e091b3c..5842d08 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ library's longer explanations, API reference and contributor verification record | --- | --- | | [Tutorial](tutorial.md) | Add your first annotations to a working form. | | [API reference](reference.md) | Look up exports, options, adapters and placement limits. | +| [Rough Notation compatibility](rough-notation-compatibility.md) | Compare the first-release surface and current limits without assuming drop-in compatibility. | | [How Stet works](explanation.md) | Understand element measurement, overlays and lifecycle behavior. | | [Agent usage](agent-usage.md) | Install project skills and use the agent CLI/workflow. | | [Visual handoff](visual-handoff.md) | Understand use cases and what Stet does or does not provide. | diff --git a/docs/delivery/backlog.md b/docs/delivery/backlog.md index 8ccba53..7336838 100644 --- a/docs/delivery/backlog.md +++ b/docs/delivery/backlog.md @@ -42,10 +42,10 @@ See [execution rules](README.md) and the approved [contracts](contracts/). | AGENT-01 | Required/final audit | F1 | AGENT-02, PW-03, AGENT-03 | ACCEPTED | | AGENT-02 | Required | F2 | AD-INTEGRATE, CORE-05, BOX-02 | ACCEPTED | | AGENT-03 | Required | F3 | PW-06, SHIP-03 | ACCEPTED | -| HANDOFF-01 | Required | D7 | PW-07, PW-08, AGENT-03 | READY | -| WEB-01 | Required | G1 | FIX-01, CORE-06 | READY | -| WEB-02 | Required | G2 | FIX-02, AGENT-02, HANDOFF-01, WEB-01 | BLOCKED | -| DOCS-01 | Required | A2/G4 | BOX-02, PW-06, SHIP-03 | READY | +| HANDOFF-01 | Required | D7 | PW-07, PW-08, AGENT-03 | ACCEPTED | +| WEB-01 | Required | G1 | FIX-01, CORE-06 | ACCEPTED | +| WEB-02 | Required | G2 | FIX-02, AGENT-02, HANDOFF-01, WEB-01 | ACCEPTED | +| DOCS-01 | Required | A2/G4 | BOX-02, PW-06, SHIP-03 | ACCEPTED | | RELEASE-01 | Required gate | H2 | CONTRACT-05, FIX-01..02, MEASURE-01, CORE-01..08, all AD, BOX-01..02, PW-01..08, SHIP-01..03, AGENT-01..03, HANDOFF-01, WEB-01..02, DOCS-01 | BLOCKED | | RELEASE-02 | Required gate | A1/G1/G2 | RELEASE-01 | BLOCKED | | FOLLOW-01..14 | Conditional/post-release | B3/B8/C2-C6/D5/E3/G3 | See task files | BLOCKED | @@ -53,11 +53,9 @@ See [execution rules](README.md) and the approved [contracts](contracts/). ## Next dispatch -`PW-01`, `SHIP-01`, `AD-REACT`, `AD-VUE`, `AD-SVELTE`, `AD-ANGULAR`, `BOX-01`, -and `WEB-01` are ready and may run subject to the shared-file writer rules. -Playwright implementation still starts only after `PW-01` and `PW-02` produce -accepted evidence. Framework writers may now start against the accepted shared -runtime and adapter contract. +`RELEASE-01` is blocked pending the pinned-Ubuntu website visual gate. After it +passes, finish manual animation review, freeze the candidate artifact, and run +its CLI consumer check before accepting `RELEASE-01` and dispatching `RELEASE-02`. ## Graph audit diff --git a/docs/delivery/evidence/DOCS-01.md b/docs/delivery/evidence/DOCS-01.md new file mode 100644 index 0000000..989bae0 --- /dev/null +++ b/docs/delivery/evidence/DOCS-01.md @@ -0,0 +1,11 @@ +# DOCS-01 evidence + +- Revision/base: `397097ed8296ea7be18c1b40beae92c1f20e64cd` plus the first-release working tree. +- Environment: Linux, Node/npm repository environment; upstream Rough Notation README retrieved from `rough-stuff/rough-notation` on 2026-09-13. +- Files changed: `docs/rough-notation-compatibility.md`, `docs/reference.md`, `docs/README.md`, and `tests/docs/`. +- Acceptance criteria checked: every public Rough Notation README capability has an available, similar-job, intentionally different, or not-in-this-release disposition. Stet's static default, circle/underline/box reveal subset, unsupported reveal errors, lifecycle, adapter handles, Playwright method/context/engine/CSP limits, shipping guarantees, accessibility boundary, and placement limits are explicit. Both references distinguish the candidate from the older registry `0.1.0`; no drop-in, WebKit, screen-reader certification, collision, agent-runtime, automatic-edit, or QA claim is made. +- Commands and exit status: `npm run check` exited `0`; `npx vitest run tests/stet.test.ts tests/rough.test.ts` exited `0` with 214 tests passed; final `npm run build --silent` exited `0`; `./node_modules/.bin/tsc -p tests/docs/tsconfig.json` exited `0`; `npm run test:templates` exited `0` with 40 snippets/patterns typechecked; `npx oxlint --deny-warnings tests/docs/rough-notation-compatibility.ts` exited `0`. +- Browser/manual artifacts: no new visual artifact required; the claim/source/test table in `docs/rough-notation-compatibility.md` links accepted browser evidence and exact suites. +- Pre-existing/initial failures: independent review initially rejected missing Rough Notation color/stroke-width/show-redraw dispositions, an inferred Playwright box promise, incomplete candidate labeling in the API reference, incomplete adapter handle syntax, a non-compilable-context snippet, and imprecise claim citations. Each was corrected before re-review. +- Unverified items and reason: no Rough Notation geometry parity is claimed. WebKit injection, shadow-root targeting, screen-reader certification, collision avoidance, and alternative build pipelines remain outside accepted evidence. +- Reviewer decision: ACCEPT after claim-table, candidate-boundary, Playwright-surface, adapter, and snippet-check corrections. diff --git a/docs/delivery/evidence/HANDOFF-01.md b/docs/delivery/evidence/HANDOFF-01.md new file mode 100644 index 0000000..c135c1d --- /dev/null +++ b/docs/delivery/evidence/HANDOFF-01.md @@ -0,0 +1,11 @@ +# HANDOFF-01 evidence + +- Revision/base: `397097ed8296ea7be18c1b40beae92c1f20e64cd` plus the HANDOFF-01 working tree. +- Environment: Linux, Node/npm from the repository environment, Playwright Chromium, fixed 900×700 viewport, light color scheme, and reduced motion. +- Files changed: `examples/handoff/`, `tests/handoff/`, `playwright.handoff.config.ts`, and delivery state/evidence files. +- Acceptance criteria checked: the durable source and temporary injected variants use `@funsaized/stet@0.1.0` from a freshly packed and offline-installed tarball. The route waits for real application state, the source plan validates, both captures retain explanatory context, native behavior is asserted independently, injected cleanup preserves the source annotation, and the broken application fixture remains detectable. The README asks for intended lifetime when ambiguous and states that Stet does not run an agent, edit automatically, or perform QA. +- Commands and exit status: initial `node agent/cli.mjs validate examples/handoff/annotation-plan.json --json` exited `0`; `npm run check` exited `0`; corrected `npx playwright test -c playwright.handoff.config.ts` exited `0` with 2 tests passed; `./test-results/pw08/consumer/node_modules/.bin/stet validate examples/handoff/annotation-plan.json --json` exited `0` with no errors or warnings. +- Browser/manual artifacts: [`source.png`](../../../test-results/handoff/source.png) and [`injected.png`](../../../test-results/handoff/injected.png). Independent visual review found a visible styled red circle in the source capture and only the expected added underline in the injected capture; text and controls remained legible. +- Pre-existing/initial failures: the first handoff browser run exited `1` with 1 pass and 1 failure because the deliberate-broken test asserted submit output without clicking submit. The test was corrected to perform the same submit interaction; this was a test defect, not a hidden application result. Independent review then rejected the first passing revision because the source page omitted packed `style.css`; the source capture had unstyled ink. Packed CSS and pre-injection style/pointer checks were added before the accepted rerun. +- Unverified items and reason: deterministic capture is established only in the pinned local Chromium environment. WebKit, cross-origin frames, cross-document arrows, navigation reuse, restrictive CSP, and cross-machine byte identity are not claimed by this example. +- Reviewer decision: ACCEPT after the CSS/reproduction corrections. Independent visual review found no blocking defects. diff --git a/docs/delivery/evidence/RELEASE-01.md b/docs/delivery/evidence/RELEASE-01.md new file mode 100644 index 0000000..5445cd7 --- /dev/null +++ b/docs/delivery/evidence/RELEASE-01.md @@ -0,0 +1,12 @@ +# RELEASE-01 evidence + +- Revision/base: `397097ed8296ea7be18c1b40beae92c1f20e64cd` plus the accepted HANDOFF-01, WEB-01/02, and DOCS-01 working tree. +- Environment: Linux 7.2.3-arch1-3 x86_64, Node `v26.7.0`, npm `11.19.0`, Playwright `1.63.0`, Chromium and Firefox. WebKit was not launched and is not claimed. +- Files changed: `tests/browser/visual.spec.ts` isolates the pinned-font screenshot file in a distinct browser worker; `website/tests/sketchbook.spec.ts` waits for post-hydration randomization and disables unrelated smooth hash scrolling before asserting scroll stability. Release evidence changed; no snapshot or budget changed. +- Acceptance criteria checked: prerequisites were accepted before starting. Initial failures remain recorded below. The full local matrix resumed only after independently reviewed bounded fixes. The gate stopped again when the required website command retained four host-baseline failures; those failures are not called a pass. +- Commands and exit status: `npm test` exited `0` (15 files, 335 tests); `npm run check` exited `0`; `npm run test:agent` exited `0` (9 files, 64 tests); `npm run test:templates` exited `0` (40 snippets/patterns); `npm run test:package` exited `0` (758,286-byte archive, 1,613,193 bytes unpacked, 320 files, 0 consumer runtime dependencies); final `npm run test:browser` exited `0` (106 passed); `npm run test:patterns` exited `0` (18 passed); `npm run size` exited `0` (circle 4,645 bytes gzip, full core 6,529 bytes gzip); final `npm run test:demos` exited `0` (20 passed); shipping exited `0` (18 passed); PW-04/05/06/07/08 exited `0` (16/14/10/1/2 passed); handoff exited `0` (2 passed); `npm --prefix website run check` exited `0`; `npm --prefix website test` exited `1` (89 passed, five failed); after the bounded scroll fix, `npm --prefix website test -- tests/sketchbook.spec.ts` exited `0` (12 passed) and `npm --prefix website test -- --grep-invert "fixed-seed"` exited `0` (88 passed). Candidate-artifact `npm run test:cli-consumer` and manual animation review were not run after the website stop condition. +- Browser/manual artifacts: the initial Firefox actual/expected/diff localized all 99 changed pixels to the U+2605 star, rendered as Firefox's missing-glyph hexbox after earlier browser files. The focused test passed twice in fresh workers but failed in the full Firefox suite, proving worker-lifetime font fallback rather than runtime geometry or antialiasing. The distinct worker fix then passed full Firefox (53) and Chromium/Firefox (106) with unchanged snapshots. Website actual/expected/diff and traces remain under `website/test-results/` for four accepted Arch-versus-Ubuntu showcase differences. Still images are not manual animation review. +- Pre-existing/initial failures: initial `test:browser` exited `1` with the 99-pixel Firefox glyph fallback described above. The first `test:demos` retry was unavailable because a stale website preview owned port 4175; the identified preview process was stopped and the unchanged suite passed. An extra PW-02 investigation rerun failed its historical assertion that `./playwright` must not be public; independent review confirmed PW-03 intentionally superseded it, the config is not a current release command, and PW-05/PW-08 plus `test:package` own its lasting criteria. The first website release run had the four previously accepted host snapshots plus a smooth-scroll race; the bounded test fix removed the functional failure without changing product code or snapshots. +- Unverified items and reason: candidate artifact/CLI consumer, manual circle/underline/box animation, WebKit, Windows, Node 20, and Ubuntu snapshot identity remain unverified because RELEASE-01 requires stopping on the required website snapshot failures and this host cannot run the pinned Ubuntu renderer. +- Known limits carried forward: no WebKit injection claim; no cross-origin/opaque/detached frames or cross-document arrows; no shadow-root evidence, collision engine, or screen-reader certification; alternate build/deploy pipelines need equivalent output checks; the published registry `0.1.0` still lacks the candidate Playwright/reveal/box surface. +- Reviewer decision: BLOCKED. Independent review accepted the Firefox worker-isolation fix and classified PW-02 as superseded, but RELEASE-01 still requires a green pinned-Ubuntu website visual run before candidate packing and manual review may resume. diff --git a/docs/delivery/evidence/WEB-01.md b/docs/delivery/evidence/WEB-01.md new file mode 100644 index 0000000..e5697f9 --- /dev/null +++ b/docs/delivery/evidence/WEB-01.md @@ -0,0 +1,11 @@ +# WEB-01 evidence + +- Revision/base: `397097ed8296ea7be18c1b40beae92c1f20e64cd` plus the WEB-01 working tree. +- Environment: Linux, website Node/npm environment, Playwright desktop Chromium and emulated iPhone 13. +- Files changed: `website/src/pages/Home.tsx`, `website/src/components/HeroDemo.tsx`, `website/src/styles.css`, `website/tests/homepage.spec.ts`, and `website/tests/sketchbook.spec.ts`. +- Acceptance criteria checked: the homepage leads with a live “Why is Save disabled?” form whose native Save state and explanation change after typing. Category, frontend/agent users, value, next action, equal starts, screenshot boundary, and optional ambient `boil` are explicit. The copied example uses only published `0.1.0` APIs and CSS; finite reveal options are absent. Keyboard Save, reduced motion, annotation toggling, viewport fit, and automated accessibility checks pass on desktop and mobile. The gallery remains below the task proof and starts. +- Commands and exit status: final `npm --prefix website run check` exited `0`; final sketchbook suite exited `0` with 12 tests passed; all 88 non-snapshot website tests exited `0`. The full 94-test release run exited `1` with 89 passed, one smooth-scroll race, and the four accepted local snapshot mismatches listed below; the scroll fix was verified separately because the required gate stopped at the remaining host snapshots. +- Browser/manual artifacts: [`home-desktop.png`](../../../test-results/web01/home-desktop.png) and [`home-mobile.png`](../../../test-results/web01/home-mobile.png). Five-second review found the category, live proof, and primary CTA visible on desktop and mobile; the equal starts follow immediately below the hero and the gallery is secondary. +- Pre-existing/initial failures: the first full run had 83 passes and 9 failures: the four accepted Arch-versus-Ubuntu showcase snapshots, a new scrollable-code accessibility issue on both projects/two suites, and one mobile shuffle scroll assertion. The command row was changed to wrapping text plus a separate copy button. The shuffle failure came from recording `scrollY` while CSS smooth hash scrolling was still active; the test now disables that unrelated motion before recording the unchanged `<5` assertion. RELEASE-01 exposed the same latent race in the scrolling regression after the homepage grew; that test now uses the same immediate hash navigation and waits for post-hydration sketch randomization before recording identity. The remaining four failures exactly match accepted evidence: workspace desktop 1,053 pixels, workspace mobile 2 pixels, security handoff 1,045 pixels, and form review 1,064 pixels. No snapshots were updated. +- Unverified items and reason: snapshot identity against the Ubuntu references remains unavailable on this host. Finite reveal is not shown because the advertised published `0.1.0` artifact does not expose it; the page honestly uses released ambient `boil` instead. +- Reviewer decision: ACCEPT. Independent review classified the four remaining screenshot mismatches as the previously accepted environmental baseline, not WEB-01 regressions. diff --git a/docs/delivery/evidence/WEB-02.md b/docs/delivery/evidence/WEB-02.md new file mode 100644 index 0000000..ffda5cd --- /dev/null +++ b/docs/delivery/evidence/WEB-02.md @@ -0,0 +1,11 @@ +# WEB-02 evidence + +- Revision/base: `397097ed8296ea7be18c1b40beae92c1f20e64cd` plus the WEB-01/02 working tree. +- Environment: Linux, website Node/npm environment, Playwright desktop Chromium and emulated iPhone 13. +- Files changed: `website/src/constants.ts`, `website/src/pages/Home.tsx`, `website/src/pages/Docs.tsx`, `website/src/pages/AgentWorkflow.tsx`, `website/src/styles.css`, and direct homepage/sketchbook tests. +- Acceptance criteria checked: equal “Write it yourself” and “Ask your agent” starts use the same scoped install and converge on the live Save circle/underline. Both cover package CSS, handle cleanup, still-by-default versus optional ambient `boil`, and the same precise lifetime question. The advertised `0.1.0` artifact lacks Playwright injection, so the stable path explicitly stops temporary-artifact requests rather than inventing an import. Copy states that Stet does not run an agent, edit automatically, apply plans, or perform QA. The hero CTA remains keyboard-operable and visible on mobile. +- Commands and exit status: `npm --prefix website run check` exited `0`; corrected equal-path browser test exited `0` with 2 tests passed across desktop/mobile; `npm run test:cli-consumer -- .release-artifacts/0.1.0/funsaized-stet-0.1.0.tgz` exited `0`. The full `npm --prefix website test` run executed 94 tests; before the task assertion correction it exited `1` with 89 passes, one WEB-02 assertion failure, and the four accepted local snapshot mismatches. +- Browser/manual artifacts: WEB-01 responsive homepage captures remain applicable; direct tests navigate the mobile/desktop CTA through both documentation paths and back to the same live Save result. +- Pre-existing/initial failures: the first WEB-02 full run expected one global underline after returning home, but the page correctly has the hero heading underline plus the Save explanation underline. The assertion was corrected to the actual two public marks and passed on both projects. The remaining four full-suite failures were the unchanged accepted Arch-versus-Ubuntu snapshots; none were updated. +- Unverified items and reason: browser injection cannot be reproduced from the advertised published `0.1.0` package because that artifact has no `./playwright` export. The website states this limit and does not advertise a checkout-only path; RELEASE-02 may change the statement only after binding the site to an artifact that actually exports it. +- Reviewer decision: ACCEPT after removal of a dead candidate handoff link and explicit current-package limitation copy. diff --git a/docs/delivery/tasks/DOCS-01.md b/docs/delivery/tasks/DOCS-01.md index 6c9ae89..53d81f3 100644 --- a/docs/delivery/tasks/DOCS-01.md +++ b/docs/delivery/tasks/DOCS-01.md @@ -1,6 +1,6 @@ # DOCS-01 — Publish compatibility and migration reference ## Outcome -Users can qualify the first-release Stet surface without inferred promises. **Parent:** A2/G4. **State:** BLOCKED. +Users can qualify the first-release Stet surface without inferred promises. **Parent:** A2/G4. **State:** ACCEPTED. ## Prerequisites BOX-02, PW-06, and SHIP-03 accepted. ## Required reading diff --git a/docs/delivery/tasks/HANDOFF-01.md b/docs/delivery/tasks/HANDOFF-01.md index 5955ea1..9e9378f 100644 --- a/docs/delivery/tasks/HANDOFF-01.md +++ b/docs/delivery/tasks/HANDOFF-01.md @@ -1,6 +1,6 @@ # HANDOFF-01 — Publish a complete handoff example ## Outcome -Another developer can reproduce source-authored and injected delivery. **Parent:** D7. **State:** BLOCKED. +Another developer can reproduce source-authored and injected delivery. **Parent:** D7. **State:** ACCEPTED. ## Prerequisites PW-07, PW-08 and AGENT-03 accepted. ## Required reading diff --git a/docs/delivery/tasks/WEB-01.md b/docs/delivery/tasks/WEB-01.md index 8ec2ed1..8acbb16 100644 --- a/docs/delivery/tasks/WEB-01.md +++ b/docs/delivery/tasks/WEB-01.md @@ -1,6 +1,6 @@ # WEB-01 — Make the homepage outcome-led ## Outcome -The first proof is a state-dependent live explanation. **Parent:** G1. **State:** READY. +The first proof is a state-dependent live explanation. **Parent:** G1. **State:** ACCEPTED. ## Prerequisites FIX-01 and CORE-06 accepted. Use the accepted animated circle/underline example; do not wait for optional examples. diff --git a/docs/delivery/tasks/WEB-02.md b/docs/delivery/tasks/WEB-02.md index 148206e..fedab8c 100644 --- a/docs/delivery/tasks/WEB-02.md +++ b/docs/delivery/tasks/WEB-02.md @@ -1,6 +1,6 @@ # WEB-02 — Add equal first-success paths ## Outcome -“Write it yourself” and “Ask your agent” reach the same released result. **Parent:** G2. **State:** BLOCKED. +“Write it yourself” and “Ask your agent” reach the same released result. **Parent:** G2. **State:** ACCEPTED. ## Prerequisites FIX-02, AGENT-02, HANDOFF-01 and WEB-01 accepted so the website has one writer. ## Required reading diff --git a/docs/reference.md b/docs/reference.md index 090cba1..b960484 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1,5 +1,11 @@ # API reference +This page describes the first-direction-release candidate in this checkout. The +registry's older `@funsaized/stet@0.1.0` artifact does not include `box`, finite +reveal/visibility handles, adapter handle callbacks, or the Playwright export. +Public proof must not advertise those additions until RELEASE-02 binds it to the +accepted candidate artifact. + ## Package exports The npm package is `@funsaized/stet`. **stet** is the product name; use the @@ -12,6 +18,7 @@ scoped package paths shown below for imports. | `@funsaized/stet/vue` | Vue directives | | `@funsaized/stet/svelte` | Svelte actions | | `@funsaized/stet/angular` | Angular standalone directives | +| `@funsaized/stet/playwright` | Candidate-only optional caller-owned Playwright injection helper; no `box` method in this release | | `@funsaized/stet/style.css` | Required layout, drawing, and motion styles | | `@funsaized/stet/agent` | Build-time plan type and validation; separate from runtime | | `@funsaized/stet/agent/capabilities.json` | Installed capability metadata | @@ -24,6 +31,7 @@ Framework packages are optional peers. The core has no runtime dependencies. ```ts circle(element: Element, options?: StetOptions): StetHandle underline(element: Element, options?: StetOptions): StetHandle +box(element: Element, options?: StetOptions): StetHandle highlight(element: Element, options?: StetOptions): StetHandle arrow(from: Element, to: Element, options?: ArrowOptions): StetHandle sticky(element: Element, options: StickyOptions): StetHandle @@ -59,6 +67,11 @@ fragments on the same line are merged. Other elements receive a box-sized wash. Multiply blending preserves dark text on light surfaces. Use `--stet-highlight-blend: screen` on dark surfaces with light text. +### `box` + +Draws a rough rectangular outline around the target. Default padding: `5` pixels. +`box`, `circle`, and `underline` are the first-release finite-reveal subset. + ### `arrow` Draws an open-headed arrow from the edge of `from` to the edge of `to`. Both @@ -104,14 +117,19 @@ Default padding: `4` pixels. | `fill` | `string` | CSS token | Highlight and sticky | | `width` | `number` | CSS token | All stroked primitives | | `resketchOnHover` | `boolean` | `false` | All primitives; controls pointer entry and press | -| `padding` | `number` | primitive-specific | Circle, underline, sticky, and mark | +| `padding` | `number` | primitive-specific | Circle, underline, box, sticky, and mark | | `description` | `string` | none | Accessible meaning for any primitive; describes `to` for arrows | +| `visible` | `boolean` | `true` | All primitives | +| `animate` | `boolean` | `false` | Circle, underline, and box reveal | +| `animationDuration` | `number` | `600` ms when enabled | Circle, underline, and box reveal | +| `animationDelay` | `number` | `0` ms | Circle, underline, and box reveal | `boil` controls frame variation. `0` generates one static frame; try `0.3` for subtle optional motion. Reduced-motion preferences force one frame and disable hover resketching, including when the preference changes while mounted. -Options are copied on attachment. To change options in vanilla, destroy and -reattach; framework adapters do this when their options change. +Animation is opt-in. Supplying duration or delay opts in unless `animate: false` +is explicit. Unsupported reveal requests throw before DOM or ARIA mutation. +Framework adapters update supported non-target options without replaying reveal. ### `ArrowOptions` @@ -151,7 +169,8 @@ adjust a crossing arrow; no automatic obstacle routing is provided. ## Exported types The core package exports `StetOptions`, `ArrowOptions`, `StickyOptions`, -`StetHandle`, and `MarkKind`. `MarkKind` is `"right" | "wrong"`. +`StetHandle`, `StetAnimationResult`, and `MarkKind`. `MarkKind` is `"right" | +"wrong"`. `@funsaized/stet/svelte` also exports `ActionReturn`, the return type shared by its actions. @@ -162,6 +181,9 @@ Every attacher returns: ```ts interface StetHandle { + show(): Promise<{ status: "finished" | "cancelled" }>; + hide(): void; + replay(): Promise<{ status: "finished" | "cancelled" }>; resketch(seed?: number): void; refresh(): void; destroy(): void; @@ -180,6 +202,10 @@ or after changing locally scoped theme tokens. It does not run a tracking loop. discarding a manually attached annotation. Calls to any handle method after destruction are harmless. +`show()` joins a reveal already in progress. `replay()` cancels an earlier +operation before starting a distinct one. `hide()` is immediate and cancels a +pending operation. Destroy settles pending reveal as cancelled. + ## React React 18 and 19 are supported. All components render `null` and attach to refs @@ -188,6 +214,7 @@ after mount. ```ts Circle(props: StetOptions & { target: RefObject }): null Underline(props: StetOptions & { target: RefObject }): null +Box(props: StetOptions & { target: RefObject }): null Highlight(props: StetOptions & { target: RefObject }): null Sticky(props: StickyOptions & { target: RefObject }): null Mark(props: StetOptions & { @@ -200,6 +227,8 @@ Arrow(props: ArrowOptions & { }): null ``` +Every component also accepts `onHandle?: (handle: StetHandle | null) => void`. + The target refs must resolve when the annotation component's effect runs. Changes to the referenced DOM node are detected on subsequent React commits, including null refs. Ref mutations that happen outside React rendering need @@ -209,10 +238,14 @@ an application render. Strict Mode cleanup is supported. Vue 3 directives attach to their host element. +Bindings may include `onHandle?: (handle: StetHandle | null) => void`; callback +metadata is not passed to the runtime or serialized into plans. + | Export | Binding value | | --- | --- | | `vStetCircle` | `StetOptions` | | `vStetUnderline` | `StetOptions` | +| `vStetBox` | `StetOptions` | | `vStetHighlight` | `StetOptions` | | `vStetSticky` | `StickyOptions` | | `vStetMark` | `StetOptions & { kind: "right" \| "wrong" }` | @@ -225,17 +258,21 @@ In ` + + + + +
+

Handoff · account security · route /examples/handoff/

+

Confirm account changes

+

+ Durable source annotation circles the new password field; it ships with the + application. Temporary Playwright annotation underlines the save button; it + exists only for this capture and is removed when the session is disposed. +

+
+ + +

At least 12 characters.

+ +
+ Not submitted +
+ + diff --git a/examples/handoff/source.js b/examples/handoff/source.js new file mode 100644 index 0000000..4e9b2d3 --- /dev/null +++ b/examples/handoff/source.js @@ -0,0 +1,10 @@ +import { circle } from "@funsaized/stet"; + +// Durable, published annotation: this mark belongs in application source. +await document.fonts.ready; +globalThis.sourceHandle = circle(document.querySelector("#password"), { + seed: 17, + boil: 0.2, + description: "New minimum: 12 characters", +}); +document.documentElement.dataset.sourceState = "ready"; diff --git a/examples/handoff/style.css b/examples/handoff/style.css new file mode 100644 index 0000000..677c8b6 --- /dev/null +++ b/examples/handoff/style.css @@ -0,0 +1,14 @@ +@font-face { + font-family: "Stet Handoff"; + src: url("/tests/browser/fonts/LiberationSans-Regular.ttf") format("truetype"); +} + +html { background: #e9e2d0; } +body { margin: 0; color: #202018; font: 18px/1.5 "Stet Handoff", sans-serif; } +#handoff { box-sizing: border-box; width: 720px; min-height: 460px; margin: 48px auto; padding: 56px; background: #fffdf5; } +.eyebrow { color: #76552c; text-transform: uppercase; letter-spacing: .08em; } +h1 { font-size: 36px; } +.legend { color: #55523f; } +form { display: grid; gap: 12px; margin: 36px 0; } +input, button { box-sizing: border-box; min-height: 44px; padding: 10px; font: inherit; } +button { border: 0; background: #153f6f; color: white; } diff --git a/playwright.handoff.config.ts b/playwright.handoff.config.ts new file mode 100644 index 0000000..b0cd340 --- /dev/null +++ b/playwright.handoff.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "tests/handoff", + outputDir: "test-results/handoff/runs", + globalSetup: "./tests/handoff/global-setup.ts", + use: { + baseURL: "http://127.0.0.1:4189", + viewport: { width: 900, height: 700 }, + colorScheme: "light", + reducedMotion: "reduce", + }, + webServer: { + command: "python -m http.server 4189 --bind 127.0.0.1", + url: "http://127.0.0.1:4189", + reuseExistingServer: !process.env.CI, + }, + projects: [{ name: "chromium", use: { browserName: "chromium" } }], +}); diff --git a/tests/browser/visual.spec.ts b/tests/browser/visual.spec.ts index 5bd2a00..0e549d6 100644 --- a/tests/browser/visual.spec.ts +++ b/tests/browser/visual.spec.ts @@ -2,8 +2,7 @@ import { expect, test } from "@playwright/test"; // Use a distinct browser worker for pinned-font screenshots. Firefox can retain // fallback-glyph choices from earlier demo pages even across browser contexts. -// This matches the normal headless launch but separates the worker fixture pool. -test.use({ launchOptions: { headless: true } }); +test.use({ launchOptions: { firefoxUserPrefs: { "stet.visual-fixture": true } } }); test.beforeEach(async ({ page }) => { await page.goto("/examples/visual/"); diff --git a/tests/docs/rough-notation-compatibility.ts b/tests/docs/rough-notation-compatibility.ts new file mode 100644 index 0000000..1f091a3 --- /dev/null +++ b/tests/docs/rough-notation-compatibility.ts @@ -0,0 +1,18 @@ +import { box, mark, type StetAnimationResult } from "@funsaized/stet"; +import "@funsaized/stet/style.css"; + +declare const target: Element; + +const annotation = box(target, { + visible: false, + animate: true, + animationDuration: 600, +}); +const result: Promise = annotation.show(); +annotation.hide(); +annotation.destroy(); + +const crossedOff = mark(target, "wrong"); +crossedOff.destroy(); + +void result; diff --git a/tests/docs/tsconfig.json b/tests/docs/tsconfig.json new file mode 100644 index 0000000..0256858 --- /dev/null +++ b/tests/docs/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "lib": ["ES2022", "DOM"], + "paths": { + "@funsaized/stet": ["../../dist/index.d.ts"], + "@funsaized/stet/style.css": ["../../style.css.d.ts"] + } + }, + "include": ["rough-notation-compatibility.ts"] +} diff --git a/tests/handoff/global-setup.ts b/tests/handoff/global-setup.ts new file mode 100644 index 0000000..f219d1d --- /dev/null +++ b/tests/handoff/global-setup.ts @@ -0,0 +1,13 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +// Reuse PW-08's build + packed-consumer preparation rather than adding a second +// packaging path. The example imports the installed tarball, not checkout dist. +export default function globalSetup(): void { + const root = fileURLToPath(new URL("../../", import.meta.url)); + execFileSync("npm", ["run", "build", "--silent"], { cwd: root, stdio: "inherit" }); + execFileSync(process.execPath, [`${root}tests/pw08/prepare-consumer.mjs`], { + cwd: root, + stdio: "inherit", + }); +} diff --git a/tests/handoff/handoff.spec.ts b/tests/handoff/handoff.spec.ts new file mode 100644 index 0000000..607c224 --- /dev/null +++ b/tests/handoff/handoff.spec.ts @@ -0,0 +1,138 @@ +import { expect, test, type Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const env: { entryUrl: string } = JSON.parse( + readFileSync(fileURLToPath(new URL("../../test-results/pw08/env.json", import.meta.url)), "utf8"), +); +const installed: { name: string; version: string; exports: Record } = JSON.parse( + readFileSync( + fileURLToPath( + new URL("../../test-results/pw08/consumer/node_modules/@funsaized/stet/package.json", import.meta.url), + ), + "utf8", + ), +); + +async function annotate(page: Page) { + const { createStet } = await import(env.entryUrl); + const before = await page.locator("#password").boundingBox(); + const session = await createStet(page); + const note = await session.underline(page.getByRole("button", { name: "Save changes" }), { + seed: 8, + description: "Save action under review", + }); + return { session, note, before }; +} + +async function verifyNativeBehavior( + page: Page, + before: { x: number; y: number; width: number; height: number } | null, +) { + // Control identity and layout are asserted separately from annotation presence. + expect(await page.evaluate(() => { + const nodes = (globalThis as Record).__handoffNodes as { + password: Element; + save: Element; + }; + return ( + nodes.password === document.querySelector("#password") && + nodes.save === document.querySelector("#save") + ); + })).toBe(true); + expect(await page.locator("#password").boundingBox()).toEqual(before); + + await page.locator("#password").fill("correct-horse-battery"); + await expect(page.locator("#password")).toBeFocused(); + await expect(page.locator("#password")).toHaveValue("correct-horse-battery"); + await expect(page.locator("#save")).toHaveAccessibleDescription(/Save action under review/); + expect( + await page.locator(".stet-overlay").first().evaluate((element) => getComputedStyle(element).pointerEvents), + ).toBe("none"); + await page.locator("#save").click(); + await expect(page.locator("#status")).toHaveText(/^Saved \d+ characters$/); +} + +test("handoff shows durable source and temporary injected variants with independent checks", async ({ page }) => { + await page.goto("/examples/handoff/"); + await page.waitForFunction( + () => + document.documentElement.dataset.sourceState === "ready" && + document.documentElement.dataset.handlers === "ready", + ); + await page.evaluate(() => document.fonts.ready); + + // Package/version context and application route/state. + expect(installed.name).toBe("@funsaized/stet"); + expect(installed.version).toBe("0.1.0"); + expect(installed.exports["./playwright"]).toBeTruthy(); + await expect(page.locator("#handoff")).toHaveAttribute("data-route", "/examples/handoff/"); + await expect(page.locator("#handoff")).toHaveAttribute("data-app-state", "ready"); + expect(new URL(page.url()).pathname).toBe("/examples/handoff/"); + + // Durable source variant exists before any injection. + await expect(page.locator(".stet-overlay")).toHaveCount(1); + await expect(page.locator(".stet-overlay--circle path").first()).toHaveCSS("stroke", "rgb(201, 42, 42)"); + await expect(page.locator(".stet-overlay--circle")).toHaveCSS("pointer-events", "none"); + await expect(page.locator("#password")).toHaveAccessibleDescription( + /At least 12 characters.*New minimum: 12 characters/, + ); + const source = await page.locator("#handoff").screenshot({ + path: "test-results/handoff/source.png", + animations: "disabled", + }); + expect((await page.locator("#handoff").screenshot({ animations: "disabled" })).equals(source)).toBe(true); + + // Temporary injected variant. + const { session, note, before } = await annotate(page); + try { + await expect(note.show()).resolves.toEqual({ status: "finished" }); + await note.refresh(); + await expect(page.locator(".stet-overlay")).toHaveCount(2); + expect(await page.evaluate(() => document.getAnimations().length)).toBe(0); + + const injected = await page.locator("#handoff").screenshot({ + path: "test-results/handoff/injected.png", + animations: "disabled", + }); + expect((await page.locator("#handoff").screenshot({ animations: "disabled" })).equals(injected)).toBe(true); + + await verifyNativeBehavior(page, before); + } finally { + await session.dispose(); + } + + // Injection removes only what it owns; the durable source mark and its + // accessible description remain. + await expect(page.locator(".stet-overlay")).toHaveCount(1); + await expect(page.locator("#password")).toHaveAccessibleDescription(/New minimum: 12 characters/); + await expect(page.locator("#save")).not.toHaveAccessibleDescription(/Save action under review/); + await expect(page.locator("#password")).toHaveAttribute( + "aria-describedby", + /^password-help stet-description-/, + ); +}); + +test("the checks detect a broken submit while annotated, and cleanup still runs", async ({ page }) => { + await page.goto("/examples/handoff/?broken=1"); + await page.waitForFunction( + () => + document.documentElement.dataset.sourceState === "ready" && + document.documentElement.dataset.handlers === "ready", + ); + + const { session } = await annotate(page); + let detected: unknown; + try { + await page.locator("#password").fill("correct-horse-battery"); + await page.locator("#save").click(); + await expect(page.locator("#status")).toHaveText(/^Saved \d+ characters$/); + } catch (error) { + detected = error; + } finally { + await session.dispose(); + } + expect(detected).toBeInstanceOf(Error); + await expect(page.locator("#status")).toHaveText("Broken fixture did not submit"); + await expect(page.locator(".stet-overlay")).toHaveCount(1); +}); diff --git a/website/src/components/HeroDemo.tsx b/website/src/components/HeroDemo.tsx index 6927401..0219d88 100644 --- a/website/src/components/HeroDemo.tsx +++ b/website/src/components/HeroDemo.tsx @@ -1,20 +1,24 @@ -import { useRef, useState, type CSSProperties } from 'react'; -import { Circle, Highlight, Sticky, Underline } from '@funsaized/stet/react'; +import { useRef, useState } from 'react'; +import { Circle, Underline } from '@funsaized/stet/react'; import { Icon } from './Icon'; + export function HeroDemo() { - const title = useRef(null), - ship = useRef(null), - task = useRef(null), - note = useRef(null); - const [marks, setMarks] = useState(true), - [seed, setSeed] = useState(12), - [shipped, setShipped] = useState(false); - const [name, setName] = useState('Something good'), - [tasks, setTasks] = useState([true, true, false]); + const save = useRef(null); + const hint = useRef(null); + const [title, setTitle] = useState(''); + const [saved, setSaved] = useState(false); + const [marks, setMarks] = useState(true); + const [motion, setMotion] = useState(false); + const [seed, setSeed] = useState(12); + const ready = title.trim().length > 0; + const boil = motion ? 0.3 : 0; + const reason = ready + ? 'Title is set. Save is available.' + : 'Save stays off until the title has a name.'; return (
- your UI, with a human touch + type a title, watch the reason
- a-small-good-thing.app + draft-note.app ↗
-
+
{ + event.preventDefault(); + if (ready) setSaved(true); + }} + >
✳︎ - THE LITTLE LAUNCH - {shipped ? 'Live!' : 'Draft'} + LIVE EXPLANATION + {saved ? 'Saved' : ready ? 'Ready' : 'Blocked'}
-

- Let’s make -
- something good. -

-

Big ideas start with little things.

-
+
- Real controls. Go on, click them. + Real controls. Go on, use them.
+ {motion ? 'boil on' : 'still'} +
); } diff --git a/website/src/constants.ts b/website/src/constants.ts index daa32b9..3fcc447 100644 --- a/website/src/constants.ts +++ b/website/src/constants.ts @@ -1,6 +1,35 @@ import capabilities from '@funsaized/stet/agent/capabilities.json'; export const REPO = 'https://github.com/funsaized/stet'; export const INSTALL = 'npm install @funsaized/stet@0.1.0'; +export const AGENT_INIT = './node_modules/.bin/stet agent init --tool codex'; +export const LIFETIME_QUESTION = + 'Should this explanation remain in the application, or exist only in this captured handoff?'; +export const SAVE_SNIPPET = `import { circle, underline } from "@funsaized/stet"; +import "@funsaized/stet/style.css"; + +export function explainSave(save: Element, hint: Element, ready: boolean) { + // Still by default. Use 0.3 for optional ambient motion. + const boil = 0; + const handles = [ + circle(save, { + seed: 12, + boil, + description: ready + ? "Save is available." + : "Save is disabled because the title is empty.", + }), + underline(hint, { + seed: 8, + boil, + description: ready + ? "The title now satisfies Save." + : "This is why Save is disabled.", + }), + ]; + return () => handles.forEach((handle) => handle.destroy()); +} +`; +export const AGENT_PROMPT = `Explain why Save is disabled using the installed @funsaized/stet@0.1.0 API. Attach circle to the Save control and underline to the reason text. Import @funsaized/stet/style.css. Destroy the handles on cleanup. Keep marks still with boil: 0, or use boil: 0.3 only if I ask for optional motion. ${LIFETIME_QUESTION} If it should remain, write it in source. If it is only a captured artifact, stop and report that the advertised 0.1.0 package does not export Playwright injection; do not invent an import. Stet does not run an agent, edit automatically, apply plans, or perform QA. I will inspect the live result; existing tests check the app.`; export type Kind = keyof typeof capabilities.primitives; export const kinds = Object.keys(capabilities.primitives) as Kind[]; export const descriptions: Record = { diff --git a/website/src/pages/AgentWorkflow.tsx b/website/src/pages/AgentWorkflow.tsx index d7b298a..16ee9c8 100644 --- a/website/src/pages/AgentWorkflow.tsx +++ b/website/src/pages/AgentWorkflow.tsx @@ -5,6 +5,7 @@ import { Artifacts } from '../showcase/Artifacts'; import { workspacePlans } from '../showcase/scenarios'; import { capabilities } from '../showcase/canonical'; import { Code } from '../components/Code'; +import { LIFETIME_QUESTION } from '../constants'; import '../showcase/showcase.css'; const stages = [ { @@ -20,7 +21,7 @@ const stages = [ reply: 'I’ll use Stet to explain the risky action and keep the existing warning and confirmation controls in place.', title: 'Choose the authoring workflow.', - body: 'stet-explain-ui guides emphasis and preservation of native warnings. Review and showcase have different goals; all use the base implementation workflow.', + body: 'stet-explain-ui guides emphasis and preservation of native warnings. Review and showcase have different goals; all use the base implementation workflow. If intended lifetime is unclear, ask: Should this explanation remain in the application, or exist only in this captured handoff?', code: 'npm install @funsaized/stet@0.1.0\n./node_modules/.bin/stet agent init --tool codex\n# Also supports claude, cursor and opencode.', }, { @@ -86,8 +87,11 @@ export function AgentWorkflow() { to something you can inspect.

- Natural language → structured plan → framework code → verified UI. Deterministic - walkthrough; no model runs on this page. + Natural language → structured plan → framework code → inspectable UI. Deterministic + walkthrough; no model runs on this page. First success for both authoring paths is the{' '} + homepage Save circle and underline. If intended lifetime is unclear, + ask: {LIFETIME_QUESTION} Stet does not run an agent, edit automatically, apply plans, or + perform QA.

diff --git a/website/src/pages/Docs.tsx b/website/src/pages/Docs.tsx index 46d6df3..6f86262 100644 --- a/website/src/pages/Docs.tsx +++ b/website/src/pages/Docs.tsx @@ -1,8 +1,18 @@ import { frameworks } from '../frameworks'; import { Link } from '@tanstack/react-router'; -import { REPO, INSTALL, kinds, descriptions } from '../constants'; +import { + REPO, + INSTALL, + AGENT_INIT, + AGENT_PROMPT, + LIFETIME_QUESTION, + SAVE_SNIPPET, + kinds, + descriptions, +} from '../constants'; import { Icon } from '../components/Icon'; import { CopyButton } from '../components/CopyButton'; +import { Code } from '../components/Code'; import { MarkGlyph } from '../components/MarkGlyph'; import { FrameworkCode } from '../components/FrameworkCode'; export function Docs() { @@ -11,7 +21,8 @@ export function Docs() {
+
+

Write it yourself

+

+ Attach the homepage Save circle and underline in source. Import the stylesheet, destroy + the handles on cleanup, and leave boil at 0 unless you want optional + motion. If intended lifetime is unclear, ask: {LIFETIME_QUESTION} Durable or reusable + explanations belong in source. The advertised 0.1.0 package does not export Playwright + injection, so stop a temporary-artifact request rather than inventing a helper import. +

+
+
+ the same Save example + +
+ +
+

+ See the homepage Save circle and underline

-

With your coding agent

+

Ask your agent

- Install Stet’s project skills from your app directory. The CLI requires Node.js 20 or - newer. The stet binary comes from the installed{' '} + Same scoped install. Install Stet’s project skills from your app directory. The CLI + requires Node.js 20 or newer. The stet binary comes from the installed{' '} @funsaized/stet package; use its local path below rather than fetching an unscoped package.

- - ./node_modules/.bin/stet agent init --tool codex + + {AGENT_INIT}

Choose codex, claude, cursor, or{' '} - opencode for your tool. The skills cover implementation, UI review, - educational feedback, and feature emphasis. + opencode for your tool. Ask for the same Save result: +

+
{AGENT_PROMPT}
+

+ You inspect the live circle and underline; your existing tests check the app. Stet does + not run an agent, edit automatically, apply plans, or perform QA. If the explanation + should remain in the application, the agent writes source. If it is only a captured + artifact, stop: do not copy a Playwright helper that is not in the installed 0.1.0 + package. +

+

+ See the homepage Save circle and underline

-

Try a request like:

-
- Use Stet to annotate this settings screen. Draw attention to the fields that need - review, preserve the existing layout and controls, and check the result in the browser. -

- The agent inspects your project and installed capabilities, validates an annotation - plan, and adapts a framework example. Review the source and live result together: a - valid plan cannot prove that a mark points to the right control or leaves text readable. + After that first result, the agent can inspect installed capabilities, validate an + annotation plan, and adapt a framework example. A valid plan cannot prove that a mark + points to the right control or leaves text readable.

Agent setup and workflow @@ -80,8 +116,8 @@ export function Docs() {

With the API

- Attach a mark to a real element. Choose your framework for an example with setup and - cleanup. + After the Save example, choose a framework for setup and cleanup. The first-success + result stays the same circle and underline.

-
- ⌘ -

Start with your agent.

+
+ 0 +

Write it yourself.

- Project skills guide annotation choices, validate plans, and provide examples for your - framework. Supports Codex, Claude Code, Cursor, and OpenCode. + Install @funsaized/stet@0.1.0, import{' '} + @funsaized/stet/style.css, attach the snippet’s circle and underline, and + destroy the handles on cleanup. boil: 0 stays still; 0.3 is + optional motion. If intended lifetime is unclear, ask: {LIFETIME_QUESTION} If it + should remain, keep it in source. If it is only a captured artifact, stop because the + advertised 0.1.0 package does not export Playwright injection.

+ + Write-path steps +
-
- 0 -

Start with the API.

+
+ ⌘ +

Ask your agent.

- Typed primitives and framework adapters let you attach marks directly. The browser - core has zero runtime dependencies. + Same scoped install, then init project skills. Ask your agent for that Save circle and + underline, including @funsaized/stet/style.css, destroy-on-cleanup, and + still-versus-boil. If lifetime is unclear, ask: {LIFETIME_QUESTION} You inspect the + live result; your tests check the app.

+
+ {AGENT_INIT} + +
+ + Copy the Save request + + + Agent-path steps +
- ↔ -

Sticks with you.

+ □ +

Why not a screenshot?

- Marks follow resizing, wrapped text, and scrolling. Life happens. The ink keeps up. + Use a screenshot when a still image is enough. Use Stet when the explanation depends + on typing, clicking, or changing state — as in Save above.

≈ -

Knows when to be still.

+

Motion stays optional.

- Motion is optional. Reduced-motion preferences are respected. A quiet kind of playful. + Marks are still by default. Optional boil is ambient motion in this advertised + release. Reduced-motion preferences stay still.

@@ -139,8 +181,9 @@ export function Home() {

Use hand-drawn annotations in documentation, product demos and implementation reviews. A - coding agent can turn a request into a validated plan and framework code, then visually - hand off its work. Your app keeps its controls; Stet adds the marks. + coding agent can author an explanation against the installed contract, then hand off the + live result and the checks that actually ran. Your app keeps its controls; Stet adds the + marks.

Product intent, UX critique and QE findings can point to the same interface. No accounts, diff --git a/website/src/styles.css b/website/src/styles.css index 2e47a2f..030bf38 100644 --- a/website/src/styles.css +++ b/website/src/styles.css @@ -480,7 +480,7 @@ font-size: 10px; margin-bottom: 8px; } - #project-name { + #note-title { width: 100%; background: #fdfdf8; border: 1px solid #dcddd2; @@ -489,6 +489,11 @@ font-size: 11px; color: #6c7163; } + .app-content .save-hint { + font-size: 11px; + margin: 14px 0 22px; + max-width: 28em; + } .task-list { margin: 21px 0 27px; display: flex; @@ -531,6 +536,13 @@ .launch-button:hover { background: #304f3a; } + .launch-button:disabled { + background: #3f4a41; + cursor: not-allowed; + } + .launch-button:disabled:hover { + background: #3f4a41; + } .launch-button.shipped { background: #365845; } @@ -546,6 +558,8 @@ display: flex; align-items: center; justify-content: space-between; + flex-wrap: wrap; + gap: 8px; margin-top: 22px; font-size: 9px; color: var(--muted); @@ -950,6 +964,41 @@ margin-top: 10px; font-size: 11px; } + .home-snippet { + margin: 18px 0 8px; + max-width: 100%; + } + .home-snippet pre { + min-height: 0; + font-size: 10px; + margin-top: 8px; + } + .principle-grid article { + min-width: 0; + } + .agent-command { + align-items: center; + background: #eeede3; + border: 1px solid #deded0; + border-radius: 4px; + display: flex; + gap: 8px; + margin-top: 12px; + padding: 10px; + font-size: 10px; + } + .agent-command code { + overflow-wrap: anywhere; + } + .agent-command .copy-button { + flex: none; + } + .agent-prompt-button { + margin-top: 8px; + } + .principle-grid p code { + font-size: 10px; + } .principle-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -965,6 +1014,10 @@ margin: 0; line-height: 1.8; } + .principle-grid .text-link { + margin-top: 10px; + font-size: 11px; + } .principle-icon { font-family: 'Caveat', cursive; color: var(--red); diff --git a/website/tests/homepage.spec.ts b/website/tests/homepage.spec.ts index dabc523..1391244 100644 --- a/website/tests/homepage.spec.ts +++ b/website/tests/homepage.spec.ts @@ -5,15 +5,19 @@ test('real controls remain usable with annotations on and off', async ({ page }) const errors: string[] = []; page.on('pageerror', (error) => errors.push(error.message)); await page.goto('/'); - await expect(page.getByRole('heading', { level: 1 })).toContainText('clarity'); - await page.getByRole('button', { name: 'Ship something good' }).scrollIntoViewIfNeeded(); + await expect(page.getByRole('heading', { level: 1 })).toContainText('interactive'); + const save = page.getByRole('button', { name: 'Save', exact: true }); + await save.scrollIntoViewIfNeeded(); + await expect(save).toBeDisabled(); await expect(page.locator('.stet-overlay--circle:not([hidden])')).toHaveCount(1); - await page.getByLabel('Project name').fill('A tiny victory'); - await page.getByLabel('Make a little noise').check(); - await page.getByRole('button', { name: 'Ship something good' }).click(); - await expect(page.getByRole('status', { name: 'Launch status' })).toContainText( - 'A tiny victory has launched', + await expect(page.locator('#save-hint')).toContainText( + 'Save stays off until the title has a name', ); + await page.getByLabel('Title', { exact: true }).fill('A tiny victory'); + await expect(save).toBeEnabled(); + await expect(page.locator('#save-hint')).toContainText('Title is set. Save is available.'); + await save.click(); + await expect(page.getByRole('status', { name: 'Save status' })).toContainText('A tiny victory'); await page.getByRole('switch', { name: 'Show annotations' }).click(); await expect(page.locator('.stet-overlay--circle:not([hidden])')).toHaveCount(0); await page.getByRole('switch', { name: 'Show annotations' }).click(); @@ -190,7 +194,7 @@ test('hero annotations stay attached during page scrolling before JavaScript upd }) => { await page.goto('/'); await page.evaluate(() => document.fonts.ready); - const button = page.getByRole('button', { name: 'Ship something good' }); + const button = page.getByRole('button', { name: 'Save', exact: true }); await button.scrollIntoViewIfNeeded(); const circle = page.locator('.stet-overlay--circle:not([hidden])'); await expect(circle).toHaveCount(1); @@ -213,3 +217,110 @@ test('hero annotations stay attached during page scrolling before JavaScript upd expect(delta.x).toBeCloseTo(0, 1); expect(delta.y).toBeCloseTo(0, 1); }); + +test('homepage states category, user, value, next action, and equal paths', async ({ page }) => { + await page.goto('/'); + const main = page.locator('main'); + await expect(main).toContainText('Code-native annotation library'); + await expect(main).toContainText('frontend developers and coding agents'); + await expect(main).toContainText('without turning the example into a screenshot'); + await expect(main).toContainText('Next action: try Save, then install'); + await expect(page.getByRole('heading', { level: 2, name: /Write it yourself/ })).toBeVisible(); + await expect(page.getByRole('heading', { level: 3, name: 'Ask your agent.' })).toBeVisible(); + await expect(main).toContainText( + 'Stet does not run a model, edit your app, apply a plan, or decide whether the UI passed', + ); + await expect(page.locator('.home-snippet pre')).toContainText('circle(save'); + await expect(page.locator('.home-snippet pre')).toContainText('underline(hint'); + await expect(page.locator('.home-snippet pre')).toContainText('boil'); + await expect(page.locator('.home-snippet pre')).not.toContainText('animate'); + await expect(page.locator('.home-snippet pre')).not.toContainText('visible'); + await expect(page.locator('.home-snippet pre')).not.toContainText('show('); + await expect(page.locator('.home-snippet pre')).not.toContainText('replay('); + await expect(page.locator('.home-snippet pre')).not.toContainText('animationDuration'); +}); + +test('keyboard can enable Save and optional boil stays still with reduced motion', async ({ + page, +}) => { + await page.goto('/'); + const title = page.getByLabel('Title', { exact: true }); + await title.scrollIntoViewIfNeeded(); + await title.focus(); + await page.keyboard.type('Keyboard note'); + await page.keyboard.press('Tab'); + await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('status', { name: 'Save status' })).toContainText('Keyboard note'); + const liveCircle = page.locator('.stet-overlay--circle:not([hidden])'); + await expect(liveCircle.locator('.stet-boil')).toHaveCount(0); + await page.getByRole('switch', { name: 'Optional motion' }).click(); + await expect(liveCircle.locator('.stet-boil').first()).toBeAttached(); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await expect(page.locator('.stet-boil')).toHaveCount(0); +}); + +test('equal first-success paths share Save, stay release-correct, and keep the mobile CTA', async ({ + page, +}) => { + await page.goto('/'); + const cta = page.locator('.hero-actions').getByRole('link', { name: /Get started/ }); + await expect(cta).toBeVisible(); + await cta.focus(); + await expect(cta).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/#install/); + const install = page.locator('#install'); + await expect( + install.getByRole('heading', { level: 3, name: 'Write it yourself.' }), + ).toBeVisible(); + await expect(install.getByRole('heading', { level: 3, name: 'Ask your agent.' })).toBeVisible(); + const write = page.locator('#write'); + const ask = page.locator('#ask'); + for (const path of [write, ask, install]) { + await expect(path).toContainText('@funsaized/stet'); + await expect(path).toContainText('style.css'); + await expect(path).toContainText('destroy'); + await expect(path).toContainText('boil'); + await expect(path).toContainText( + 'Should this explanation remain in the application, or exist only in this captured handoff?', + ); + await expect(path).not.toContainText('@funsaized/stet/playwright'); + await expect(path).not.toContainText('createStet'); + } + const snippet = await page.locator('.home-snippet pre').innerText(); + expect(snippet).toContain('circle(save'); + expect(snippet).toContain('underline(hint'); + expect(snippet).toContain('@funsaized/stet/style.css'); + expect(snippet).toContain('handle.destroy()'); + expect(snippet).not.toMatch(/animate|visible|show\(|replay\(|animationDuration|playwright/); + await page.goto('/docs'); + await page.getByRole('link', { name: 'Write it yourself', exact: true }).click(); + await expect(page.locator('#write pre')).toHaveText(snippet); + await expect(page.locator('#write')).toContainText( + 'Should this explanation remain in the application, or exist only in this captured handoff?', + ); + await page.getByRole('link', { name: 'Ask your agent', exact: true }).click(); + await expect(page.locator('#agents')).toContainText('@funsaized/stet/style.css'); + await expect(page.locator('#agents')).toContainText('Destroy the handles on cleanup'); + await expect(page.locator('#agents')).toContainText('boil: 0'); + await expect(page.locator('#agents')).toContainText( + 'Should this explanation remain in the application, or exist only in this captured handoff?', + ); + await expect(page.locator('#agents')).toContainText( + 'Stet does not run an agent, edit automatically, apply plans, or perform QA', + ); + await expect(page.locator('#agents')).not.toContainText('@funsaized/stet/playwright'); + await expect(page.locator('#agents')).not.toContainText('createStet'); + await expect(page.locator('#agents')).toContainText( + 'advertised 0.1.0 package does not export Playwright injection', + ); + await page + .locator('#agents') + .getByRole('link', { name: /homepage Save circle and underline/ }) + .click(); + await expect(page).toHaveURL(/\/$/); + await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeVisible(); + await expect(page.locator('.stet-overlay--circle:not([hidden])')).toHaveCount(1); + await expect(page.locator('.stet-overlay--underline:not([hidden])')).toHaveCount(2); +}); diff --git a/website/tests/sketchbook.spec.ts b/website/tests/sketchbook.spec.ts index 71f0cb2..2f3bf9f 100644 --- a/website/tests/sketchbook.spec.ts +++ b/website/tests/sketchbook.spec.ts @@ -5,7 +5,7 @@ test('homepage keeps code and playground tools on their own pages', async ({ pag await page.goto('/'); await expect(page.getByRole('tablist')).toHaveCount(0); await expect(page.locator('.framework-code')).toHaveCount(0); - await expect(page.locator('#install')).toContainText('AGENT AND DEVELOPER FIRST.'); + await expect(page.locator('#install')).toContainText('TWO EQUAL STARTS.'); await page.getByRole('link', { name: 'Playground', exact: true }).click(); await expect(page).toHaveURL(/\/playground$/); await expect(page.getByRole('heading', { level: 1 })).toContainText('Six ways'); @@ -18,15 +18,22 @@ test('homepage keeps code and playground tools on their own pages', async ({ pag }); test('shuffle changes arrangement, ink geometry, and palette without jumping', async ({ page }) => { - await page.goto('/#sketchbook'); - await page.locator('.sketchbook-controls').scrollIntoViewIfNeeded(); + await page.goto('/'); + await page.evaluate(() => { + document.documentElement.style.scrollBehavior = 'auto'; + location.hash = 'sketchbook'; + }); + const shuffle = page.getByRole('button', { name: 'Shuffle everything' }); + await shuffle.focus(); + await expect(shuffle).toBeFocused(); await expect(page.locator('.loose-sketch').first()).toBeAttached(); const book = page.locator('#sketchbook'); + await expect(book).not.toHaveAttribute('data-edition', '42'); const edition = await book.getAttribute('data-edition'); const palette = await book.getAttribute('data-palette'); const seed = await page.locator('.loose-sketch').first().getAttribute('data-sketch-seed'); const scroll = await page.evaluate(() => scrollY); - await page.getByRole('button', { name: 'Shuffle everything' }).click(); + await shuffle.click(); await expect(book).not.toHaveAttribute('data-edition', edition!); await expect(book).not.toHaveAttribute('data-palette', palette!); await expect(page.locator('.loose-sketch').first()).not.toHaveAttribute( @@ -42,9 +49,14 @@ test('scrolling preserves the fixed list, its DOM nodes, state, and page length' }) => { const errors: string[] = []; page.on('pageerror', (error) => errors.push(error.message)); - await page.goto('/#sketchbook'); + await page.goto('/'); + await page.evaluate(() => { + document.documentElement.style.scrollBehavior = 'auto'; + location.hash = 'sketchbook'; + }); await page.evaluate(() => document.fonts.ready); await expect(page.locator('.loose-sketch')).toHaveCount(12); + await expect(page.locator('#sketchbook')).not.toHaveAttribute('data-edition', '42'); const before = await page .locator('.loose-sketch') .evaluateAll((nodes) => nodes.map((n) => n.getAttribute('data-sketch-seed'))); From 76a6a74e40b93d709705363d4603dccd9a62db0e Mon Sep 17 00:00:00 2001 From: Sai Nimmagadda Date: Sun, 13 Sep 2026 14:59:12 -0400 Subject: [PATCH 08/11] fix: stabilize release checks across CI --- scripts/agent/check-package.mjs | 21 +++++++++--------- .../box-geometry-chromium-ubuntu-24.04.png | Bin 0 -> 3448 bytes .../box-geometry-firefox-ubuntu-24.04.png | Bin 0 -> 6330 bytes .../box-geometry-webkit-ubuntu-24.04.png | Bin 0 -> 4066 bytes website/src/styles.css | 4 ++-- website/tests/sketchbook.spec.ts | 1 + 6 files changed, 13 insertions(+), 13 deletions(-) create mode 100644 tests/browser/visual.spec.ts-snapshots/box-geometry-chromium-ubuntu-24.04.png create mode 100644 tests/browser/visual.spec.ts-snapshots/box-geometry-firefox-ubuntu-24.04.png create mode 100644 tests/browser/visual.spec.ts-snapshots/box-geometry-webkit-ubuntu-24.04.png diff --git a/scripts/agent/check-package.mjs b/scripts/agent/check-package.mjs index 3d3e7a4..dcac313 100644 --- a/scripts/agent/check-package.mjs +++ b/scripts/agent/check-package.mjs @@ -9,17 +9,16 @@ const cwd = mkdtempSync(join(tmpdir(), "stet-package-")); const run = (cmd, args, options = {}) => execFileSync(cmd, args, { encoding: "utf8", ...options }); try { run("npm", ["run", "build"], { stdio: "inherit" }); - const [pack] = JSON.parse( - run("npm", [ - "pack", - "--ignore-scripts", - "--json", - "--cache", - join(cwd, "cache"), - "--pack-destination", - cwd, - ]), - ); + const packOutput = run("npm", [ + "pack", + "--ignore-scripts", + "--json", + "--cache", + join(cwd, "cache"), + "--pack-destination", + cwd, + ]); + const [pack] = JSON.parse(packOutput.slice(Math.max(0, packOutput.lastIndexOf("\n[") + 1))); const paths = new Set(pack.files.map((f) => f.path)); const pkg = JSON.parse(readFileSync("package.json", "utf8")); assert.deepEqual(pkg.peerDependencies, { diff --git a/tests/browser/visual.spec.ts-snapshots/box-geometry-chromium-ubuntu-24.04.png b/tests/browser/visual.spec.ts-snapshots/box-geometry-chromium-ubuntu-24.04.png new file mode 100644 index 0000000000000000000000000000000000000000..d1ba597140e8560fe35d1ea1edff85694c3483ca GIT binary patch literal 3448 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 literal 0 HcmV?d00001 diff --git a/tests/browser/visual.spec.ts-snapshots/box-geometry-webkit-ubuntu-24.04.png b/tests/browser/visual.spec.ts-snapshots/box-geometry-webkit-ubuntu-24.04.png new file mode 100644 index 0000000000000000000000000000000000000000..a1f9b64d0c491f8d5a74c382842990618e7e4368 GIT binary patch literal 4066 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 T6m5NhIUQtSYGYD&$vffSqGO$H literal 0 HcmV?d00001 diff --git a/website/src/styles.css b/website/src/styles.css index 030bf38..e022ad1 100644 --- a/website/src/styles.css +++ b/website/src/styles.css @@ -1051,8 +1051,8 @@ width: 100%; } .install-command code { - overflow-x: auto; - white-space: nowrap; + overflow-wrap: anywhere; + white-space: normal; text-align: left; flex: 1; } diff --git a/website/tests/sketchbook.spec.ts b/website/tests/sketchbook.spec.ts index 2f3bf9f..bd573a6 100644 --- a/website/tests/sketchbook.spec.ts +++ b/website/tests/sketchbook.spec.ts @@ -24,6 +24,7 @@ test('shuffle changes arrangement, ink geometry, and palette without jumping', a location.hash = 'sketchbook'; }); const shuffle = page.getByRole('button', { name: 'Shuffle everything' }); + await shuffle.scrollIntoViewIfNeeded(); await shuffle.focus(); await expect(shuffle).toBeFocused(); await expect(page.locator('.loose-sketch').first()).toBeAttached(); From 26c3f0d288a4c74767eeb45d08cba140b457d6fd Mon Sep 17 00:00:00 2001 From: Sai Nimmagadda Date: Sun, 13 Sep 2026 15:04:12 -0400 Subject: [PATCH 09/11] test: avoid mobile click auto-scroll --- website/tests/sketchbook.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/tests/sketchbook.spec.ts b/website/tests/sketchbook.spec.ts index bd573a6..5f0c9d5 100644 --- a/website/tests/sketchbook.spec.ts +++ b/website/tests/sketchbook.spec.ts @@ -34,7 +34,7 @@ test('shuffle changes arrangement, ink geometry, and palette without jumping', a const palette = await book.getAttribute('data-palette'); const seed = await page.locator('.loose-sketch').first().getAttribute('data-sketch-seed'); const scroll = await page.evaluate(() => scrollY); - await shuffle.click(); + await page.keyboard.press('Enter'); await expect(book).not.toHaveAttribute('data-edition', edition!); await expect(book).not.toHaveAttribute('data-palette', palette!); await expect(page.locator('.loose-sketch').first()).not.toHaveAttribute( From 91572e51504f2d01704d798991262737bcffe89c Mon Sep 17 00:00:00 2001 From: Sai Nimmagadda Date: Sun, 13 Sep 2026 15:46:25 -0400 Subject: [PATCH 10/11] chore: prepare 0.2.0 candidate --- CHANGELOG.md | 4 +- README.md | 2 +- agent/capabilities.json | 2 +- agent/schemas/capabilities.schema.json | 4 +- docs/agent-usage.md | 2 +- docs/reference.md | 6 +-- docs/releases.md | 2 +- docs/rough-notation-compatibility.md | 6 +-- docs/showcase-verification.md | 6 +-- docs/tutorial.md | 4 +- examples/handoff/README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- tests/handoff/handoff.spec.ts | 55 +++++++++++++++++--------- 14 files changed, 60 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c9f73..bb2dc30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,13 @@ # Changelog -## Unreleased +## 0.2.0 — 2026-09-13 - Add a unified website showcase with six live use cases, Product/UX/Engineering/QE examples for workspace deletion, billing, search, security and import validation, a deterministic agent walkthrough, and inspectable plans, actual source, canonical framework examples and verification scope. - Add sticky `offsetX`/`offsetY` and arrow `labelOffsetX`/`labelOffsetY` pixel nudges after automatic placement and before viewport clamping. Defaults preserve existing - placement. Core grows by 38 bytes gzip to 6.83 KiB; the 7 KiB guard is unchanged. + placement. Core grows by 38 bytes gzip and remains below the 11 KiB guard. - Generate updated option contracts and snippets; add evidence-based handoff guidance within existing skills without changing routing or adding a skill. - Make playground code match its preview, including seed, motion and placement; diff --git a/README.md b/README.md index 2ab37d1..459c943 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Use `@funsaized/stet` in installation commands and imports. ![STET annotating a working release form with pen marks and a paper note](docs/visual/vanilla-after.png) ```sh -npm install @funsaized/stet@0.1.0 +npm install @funsaized/stet@0.2.0 ``` Stet's browser API is ESM-only and uses DOM APIs, so call it from client code diff --git a/agent/capabilities.json b/agent/capabilities.json index 5e2a976..f7a3a52 100644 --- a/agent/capabilities.json +++ b/agent/capabilities.json @@ -2,7 +2,7 @@ "contractVersion": 1, "package": { "name": "@funsaized/stet", - "version": "0.1.0", + "version": "0.2.0", "exports": [ ".", "./react", diff --git a/agent/schemas/capabilities.schema.json b/agent/schemas/capabilities.schema.json index d306175..a01f58d 100644 --- a/agent/schemas/capabilities.schema.json +++ b/agent/schemas/capabilities.schema.json @@ -1,12 +1,12 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Stet 0.1.0 capabilities", + "title": "Stet 0.2.0 capabilities", "type": "object", "const": { "contractVersion": 1, "package": { "name": "@funsaized/stet", - "version": "0.1.0", + "version": "0.2.0", "exports": [ ".", "./react", diff --git a/docs/agent-usage.md b/docs/agent-usage.md index d0e1af0..1542c9b 100644 --- a/docs/agent-usage.md +++ b/docs/agent-usage.md @@ -4,7 +4,7 @@ The npm package is **@funsaized/stet**; its local CLI binary is **stet**. Instal in the application package, then install project skills for your tool: ```sh -npm install @funsaized/stet@0.1.0 +npm install @funsaized/stet@0.2.0 ./node_modules/.bin/stet agent init --tool codex ``` diff --git a/docs/reference.md b/docs/reference.md index b960484..b2c8588 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1,7 +1,7 @@ # API reference -This page describes the first-direction-release candidate in this checkout. The -registry's older `@funsaized/stet@0.1.0` artifact does not include `box`, finite +This page describes the `0.2.0` candidate. The registry's older +`@funsaized/stet@0.1.0` artifact does not include `box`, finite reveal/visibility handles, adapter handle callbacks, or the Playwright export. Public proof must not advertise those additions until RELEASE-02 binds it to the accepted candidate artifact. @@ -161,7 +161,7 @@ sticky(button, { text: "Review this change.", side: "right", offsetY: 24 }); arrow(from, to, { label: "Changed", curvature: -0.2, labelOffsetY: -18 }); ``` -These additions are in this checkout, not the published 0.1.0 package. Inspect +These additions are in the 0.2.0 candidate, not the published 0.1.0 package. Inspect installed capabilities before copying them. Try fewer marks, shorter copy, existing `side`/`padding`, or removing redundant labels first. Use `curvature` to adjust a crossing arrow; no automatic obstacle routing is provided. diff --git a/docs/releases.md b/docs/releases.md index d252b8a..3902991 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -36,7 +36,7 @@ access token (classic) with `read:packages` and access to this package: ```sh npm login --scope=@funsaized --auth-type=legacy --registry=https://npm.pkg.github.com -npm install @funsaized/stet@0.1.0 --registry=https://npm.pkg.github.com +npm install @funsaized/stet@0.2.0 --registry=https://npm.pkg.github.com ``` Alternatively, use environment substitution in your consuming project's `.npmrc`: diff --git a/docs/rough-notation-compatibility.md b/docs/rough-notation-compatibility.md index c86fe0c..1ae872f 100644 --- a/docs/rough-notation-compatibility.md +++ b/docs/rough-notation-compatibility.md @@ -1,8 +1,8 @@ # Rough Notation compatibility for the first release -This reference describes Stet's first-direction-release candidate, not the -registry's older `@funsaized/stet@0.1.0` artifact. RELEASE-02 binds public proof -to the accepted candidate artifact before release. Stet is not a drop-in Rough +This reference describes Stet's `0.2.0` candidate, not the registry's older +`@funsaized/stet@0.1.0` artifact. Public proof remains on the older artifact until +the candidate is published. Stet is not a drop-in Rough Notation replacement: names, defaults, handles, framework adapters, and lifecycle semantics differ. diff --git a/docs/showcase-verification.md b/docs/showcase-verification.md index 7239c89..3fec10c 100644 --- a/docs/showcase-verification.md +++ b/docs/showcase-verification.md @@ -27,9 +27,9 @@ npm --prefix website run check npm --prefix website test ``` -The implementation also adds sticky position offsets and arrow label offsets; -these remain in the Unreleased changelog. The site uses the local source through -Vite aliases. No package release or hosting deployment is implied by these checks. +The implementation also adds sticky position offsets and arrow label offsets in +the 0.2.0 candidate. The site remains on published 0.1.0 until RELEASE-02. No +package release or hosting deployment is implied by these checks. Fixtures keep changes in local component state. Security controls do not perform real authentication; import validation checks filenames, not CSV contents. Browser diff --git a/docs/tutorial.md b/docs/tutorial.md index a1d515a..b2e8088 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -16,7 +16,7 @@ npm pack ``` The command prints an archive name containing the current package version, -for example `funsaized-stet-0.1.0.tgz`. Use the actual printed filename below. +for example `funsaized-stet-0.2.0.tgz`. Use the actual printed filename below. Keep this terminal in the repository root. ## 2. Create a small browser app @@ -28,7 +28,7 @@ cd .. npm create vite@latest stet-demo -- --template vanilla cd stet-demo npm install -npm install ../stet/funsaized-stet-0.1.0.tgz # use the filename printed by npm pack +npm install ../stet/funsaized-stet-0.2.0.tgz # use the filename printed by npm pack ``` Replace `index.html` with this form: diff --git a/examples/handoff/README.md b/examples/handoff/README.md index 2952d71..841eb17 100644 --- a/examples/handoff/README.md +++ b/examples/handoff/README.md @@ -1,7 +1,7 @@ # Reproducible source and injected handoff This example explains the account-security form at `/examples/handoff/` after -both application modules report ready. It uses `@funsaized/stet@0.1.0` installed +both application modules report ready. It uses `@funsaized/stet@0.2.0` installed from the tarball packed by the test setup, rather than a checkout-only import. Run it from the repository root: diff --git a/package-lock.json b/package-lock.json index 1e9f3a4..da113e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@funsaized/stet", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@funsaized/stet", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "bin": { "stet": "agent/cli.mjs" diff --git a/package.json b/package.json index 0375ada..d95611a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@funsaized/stet", - "version": "0.1.0", + "version": "0.2.0", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" diff --git a/tests/handoff/handoff.spec.ts b/tests/handoff/handoff.spec.ts index 607c224..a7e367d 100644 --- a/tests/handoff/handoff.spec.ts +++ b/tests/handoff/handoff.spec.ts @@ -8,7 +8,10 @@ const env: { entryUrl: string } = JSON.parse( const installed: { name: string; version: string; exports: Record } = JSON.parse( readFileSync( fileURLToPath( - new URL("../../test-results/pw08/consumer/node_modules/@funsaized/stet/package.json", import.meta.url), + new URL( + "../../test-results/pw08/consumer/node_modules/@funsaized/stet/package.json", + import.meta.url, + ), ), "utf8", ), @@ -30,16 +33,18 @@ async function verifyNativeBehavior( before: { x: number; y: number; width: number; height: number } | null, ) { // Control identity and layout are asserted separately from annotation presence. - expect(await page.evaluate(() => { - const nodes = (globalThis as Record).__handoffNodes as { - password: Element; - save: Element; - }; - return ( - nodes.password === document.querySelector("#password") && - nodes.save === document.querySelector("#save") - ); - })).toBe(true); + expect( + await page.evaluate(() => { + const nodes = (globalThis as Record).__handoffNodes as { + password: Element; + save: Element; + }; + return ( + nodes.password === document.querySelector("#password") && + nodes.save === document.querySelector("#save") + ); + }), + ).toBe(true); expect(await page.locator("#password").boundingBox()).toEqual(before); await page.locator("#password").fill("correct-horse-battery"); @@ -47,13 +52,18 @@ async function verifyNativeBehavior( await expect(page.locator("#password")).toHaveValue("correct-horse-battery"); await expect(page.locator("#save")).toHaveAccessibleDescription(/Save action under review/); expect( - await page.locator(".stet-overlay").first().evaluate((element) => getComputedStyle(element).pointerEvents), + await page + .locator(".stet-overlay") + .first() + .evaluate((element) => getComputedStyle(element).pointerEvents), ).toBe("none"); await page.locator("#save").click(); await expect(page.locator("#status")).toHaveText(/^Saved \d+ characters$/); } -test("handoff shows durable source and temporary injected variants with independent checks", async ({ page }) => { +test("handoff shows durable source and temporary injected variants with independent checks", async ({ + page, +}) => { await page.goto("/examples/handoff/"); await page.waitForFunction( () => @@ -64,7 +74,7 @@ test("handoff shows durable source and temporary injected variants with independ // Package/version context and application route/state. expect(installed.name).toBe("@funsaized/stet"); - expect(installed.version).toBe("0.1.0"); + expect(installed.version).toBe("0.2.0"); expect(installed.exports["./playwright"]).toBeTruthy(); await expect(page.locator("#handoff")).toHaveAttribute("data-route", "/examples/handoff/"); await expect(page.locator("#handoff")).toHaveAttribute("data-app-state", "ready"); @@ -72,7 +82,10 @@ test("handoff shows durable source and temporary injected variants with independ // Durable source variant exists before any injection. await expect(page.locator(".stet-overlay")).toHaveCount(1); - await expect(page.locator(".stet-overlay--circle path").first()).toHaveCSS("stroke", "rgb(201, 42, 42)"); + await expect(page.locator(".stet-overlay--circle path").first()).toHaveCSS( + "stroke", + "rgb(201, 42, 42)", + ); await expect(page.locator(".stet-overlay--circle")).toHaveCSS("pointer-events", "none"); await expect(page.locator("#password")).toHaveAccessibleDescription( /At least 12 characters.*New minimum: 12 characters/, @@ -81,7 +94,9 @@ test("handoff shows durable source and temporary injected variants with independ path: "test-results/handoff/source.png", animations: "disabled", }); - expect((await page.locator("#handoff").screenshot({ animations: "disabled" })).equals(source)).toBe(true); + expect( + (await page.locator("#handoff").screenshot({ animations: "disabled" })).equals(source), + ).toBe(true); // Temporary injected variant. const { session, note, before } = await annotate(page); @@ -95,7 +110,9 @@ test("handoff shows durable source and temporary injected variants with independ path: "test-results/handoff/injected.png", animations: "disabled", }); - expect((await page.locator("#handoff").screenshot({ animations: "disabled" })).equals(injected)).toBe(true); + expect( + (await page.locator("#handoff").screenshot({ animations: "disabled" })).equals(injected), + ).toBe(true); await verifyNativeBehavior(page, before); } finally { @@ -113,7 +130,9 @@ test("handoff shows durable source and temporary injected variants with independ ); }); -test("the checks detect a broken submit while annotated, and cleanup still runs", async ({ page }) => { +test("the checks detect a broken submit while annotated, and cleanup still runs", async ({ + page, +}) => { await page.goto("/examples/handoff/?broken=1"); await page.waitForFunction( () => From ae70d9726eda06ed5de88e428c84595aeb5d0a22 Mon Sep 17 00:00:00 2001 From: Sai Nimmagadda Date: Sat, 19 Sep 2026 07:51:47 -0400 Subject: [PATCH 11/11] feat: update site --- .github/workflows/website.yml | 3 + .gitignore | 1 + docs/delivery/backlog.md | 8 +- docs/delivery/evidence/RELEASE-01.md | 18 +- docs/delivery/evidence/RELEASE-02.md | 8 + docs/delivery/tasks/RELEASE-01.md | 2 +- docs/website-refactor-plan.md | 394 ++++++++++++++ website/README.md | 100 ++-- website/package-lock.json | 18 +- website/package.json | 9 +- .../images/example-feature-showcase.png | Bin 0 -> 40110 bytes website/public/images/example-form-review.png | Bin 0 -> 31930 bytes .../public/images/example-guided-tutorial.png | Bin 0 -> 31352 bytes .../images/example-live-documentation.png | Bin 0 -> 27609 bytes .../images/example-security-handoff.png | Bin 0 -> 40590 bytes .../images/example-workspace-deletion.png | Bin 0 -> 35213 bytes website/public/images/playwright-handoff.png | Bin 0 -> 41034 bytes website/public/social.png | Bin 62754 -> 65396 bytes website/public/social.svg | 16 +- website/scripts/capture-examples.mjs | 30 + website/scripts/capture-handoff.mjs | 58 ++ website/scripts/check-framework-guides.mjs | 143 +++++ website/scripts/check-playground-snippets.mjs | 198 +++++++ website/scripts/prerender.mjs | 10 +- website/scripts/test-seo.mjs | 14 +- website/src/components/FrameworkCode.tsx | 62 ++- website/src/components/HeroDemo.tsx | 26 +- website/src/components/Layout.tsx | 70 ++- website/src/components/MarkGlyph.tsx | 1 + website/src/components/Playground.tsx | 511 ++++++++++++++---- website/src/components/playground-snippet.ts | 174 ++++++ website/src/components/playground.css | 181 +++++++ website/src/constants.ts | 90 +-- website/src/docs.css | 84 +++ website/src/frameworks.ts | 190 ++++++- website/src/pages/AgentWorkflow.tsx | 271 +++++----- website/src/pages/Docs.tsx | 419 ++++++++++---- website/src/pages/FrameworkDocs.tsx | 134 +++-- website/src/pages/Home.tsx | 347 ++++++------ website/src/pages/UseCases.tsx | 244 ++++----- website/src/product.css | 313 +++++++++++ website/src/seo.ts | 14 +- website/src/showcase/Artifacts.tsx | 51 +- website/src/showcase/Capture.tsx | 17 + website/src/showcase/Deployment.tsx | 4 +- website/src/showcase/Release.tsx | 54 +- website/src/showcase/options.ts | 4 +- website/src/showcase/scenarios.ts | 110 +++- website/src/showcase/showcase.css | 117 ++++ website/src/usePageMeta.ts | 3 +- website/tests/docs.spec.ts | 110 ++++ website/tests/homepage.spec.ts | 149 ++--- website/tests/navigation.spec.ts | 71 +++ website/tests/playground.spec.ts | 121 +++++ website/tests/refactor-examples.spec.ts | 192 +++++++ website/tests/seo.spec.ts | 4 + website/tests/showcase.spec.ts | 165 +++--- .../form-review-desktop-linux.png | Bin 649649 -> 543585 bytes .../form-review-mobile-linux.png | Bin 111477 -> 111941 bytes .../security-handoff-desktop-linux.png | Bin 539453 -> 357002 bytes .../security-handoff-mobile-linux.png | Bin 59388 -> 58262 bytes .../workspace-desktop-linux.png | Bin 688016 -> 504337 bytes .../workspace-mobile-linux.png | Bin 75921 -> 71193 bytes website/tests/sketchbook.spec.ts | 145 ----- website/vendor/README.md | 16 + website/vendor/funsaized-stet-0.2.0.tgz | Bin 0 -> 759895 bytes website/vite.config.ts | 24 +- 67 files changed, 4228 insertions(+), 1290 deletions(-) create mode 100644 docs/delivery/evidence/RELEASE-02.md create mode 100644 docs/website-refactor-plan.md create mode 100644 website/public/images/example-feature-showcase.png create mode 100644 website/public/images/example-form-review.png create mode 100644 website/public/images/example-guided-tutorial.png create mode 100644 website/public/images/example-live-documentation.png create mode 100644 website/public/images/example-security-handoff.png create mode 100644 website/public/images/example-workspace-deletion.png create mode 100644 website/public/images/playwright-handoff.png create mode 100644 website/scripts/capture-examples.mjs create mode 100644 website/scripts/capture-handoff.mjs create mode 100644 website/scripts/check-framework-guides.mjs create mode 100644 website/scripts/check-playground-snippets.mjs create mode 100644 website/src/components/playground-snippet.ts create mode 100644 website/src/components/playground.css create mode 100644 website/src/docs.css create mode 100644 website/src/product.css create mode 100644 website/src/showcase/Capture.tsx create mode 100644 website/tests/docs.spec.ts create mode 100644 website/tests/navigation.spec.ts create mode 100644 website/tests/playground.spec.ts create mode 100644 website/tests/refactor-examples.spec.ts delete mode 100644 website/tests/sketchbook.spec.ts create mode 100644 website/vendor/README.md create mode 100644 website/vendor/funsaized-stet-0.2.0.tgz diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index f67137d..1dabcd9 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -34,6 +34,9 @@ jobs: working-directory: . - run: npm ci - run: npm run check + env: + # Quality artifacts are previews; public builds must use a registry pin. + STET_CANDIDATE_PREVIEW: "1" - run: npx playwright install --with-deps chromium - run: npm test - uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index e9654e7..3c398af 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ test-results/ playwright-report/ .playwright-mcp/ .release-artifacts/ +!/website/vendor/funsaized-stet-0.2.0.tgz diff --git a/docs/delivery/backlog.md b/docs/delivery/backlog.md index 7336838..63d1eb3 100644 --- a/docs/delivery/backlog.md +++ b/docs/delivery/backlog.md @@ -46,16 +46,16 @@ See [execution rules](README.md) and the approved [contracts](contracts/). | WEB-01 | Required | G1 | FIX-01, CORE-06 | ACCEPTED | | WEB-02 | Required | G2 | FIX-02, AGENT-02, HANDOFF-01, WEB-01 | ACCEPTED | | DOCS-01 | Required | A2/G4 | BOX-02, PW-06, SHIP-03 | ACCEPTED | -| RELEASE-01 | Required gate | H2 | CONTRACT-05, FIX-01..02, MEASURE-01, CORE-01..08, all AD, BOX-01..02, PW-01..08, SHIP-01..03, AGENT-01..03, HANDOFF-01, WEB-01..02, DOCS-01 | BLOCKED | +| RELEASE-01 | Required gate | H2 | CONTRACT-05, FIX-01..02, MEASURE-01, CORE-01..08, all AD, BOX-01..02, PW-01..08, SHIP-01..03, AGENT-01..03, HANDOFF-01, WEB-01..02, DOCS-01 | ACCEPTED | | RELEASE-02 | Required gate | A1/G1/G2 | RELEASE-01 | BLOCKED | | FOLLOW-01..14 | Conditional/post-release | B3/B8/C2-C6/D5/E3/G3 | See task files | BLOCKED | | EVIDENCE-01..08 | Human-led | G5/G6/H1/F5/F6 | See task files | BLOCKED | ## Next dispatch -`RELEASE-01` is blocked pending the pinned-Ubuntu website visual gate. After it -passes, finish manual animation review, freeze the candidate artifact, and run -its CLI consumer check before accepting `RELEASE-01` and dispatching `RELEASE-02`. +Pause for the requested heavy pre-merge review. `RELEASE-02` remains blocked +until the accepted `0.2.0` artifact is published; do not bind the stable website +to an unavailable registry version or a checkout-only tarball path. ## Graph audit diff --git a/docs/delivery/evidence/RELEASE-01.md b/docs/delivery/evidence/RELEASE-01.md index 5445cd7..c9b9cff 100644 --- a/docs/delivery/evidence/RELEASE-01.md +++ b/docs/delivery/evidence/RELEASE-01.md @@ -1,12 +1,10 @@ # RELEASE-01 evidence -- Revision/base: `397097ed8296ea7be18c1b40beae92c1f20e64cd` plus the accepted HANDOFF-01, WEB-01/02, and DOCS-01 working tree. -- Environment: Linux 7.2.3-arch1-3 x86_64, Node `v26.7.0`, npm `11.19.0`, Playwright `1.63.0`, Chromium and Firefox. WebKit was not launched and is not claimed. -- Files changed: `tests/browser/visual.spec.ts` isolates the pinned-font screenshot file in a distinct browser worker; `website/tests/sketchbook.spec.ts` waits for post-hydration randomization and disables unrelated smooth hash scrolling before asserting scroll stability. Release evidence changed; no snapshot or budget changed. -- Acceptance criteria checked: prerequisites were accepted before starting. Initial failures remain recorded below. The full local matrix resumed only after independently reviewed bounded fixes. The gate stopped again when the required website command retained four host-baseline failures; those failures are not called a pass. -- Commands and exit status: `npm test` exited `0` (15 files, 335 tests); `npm run check` exited `0`; `npm run test:agent` exited `0` (9 files, 64 tests); `npm run test:templates` exited `0` (40 snippets/patterns); `npm run test:package` exited `0` (758,286-byte archive, 1,613,193 bytes unpacked, 320 files, 0 consumer runtime dependencies); final `npm run test:browser` exited `0` (106 passed); `npm run test:patterns` exited `0` (18 passed); `npm run size` exited `0` (circle 4,645 bytes gzip, full core 6,529 bytes gzip); final `npm run test:demos` exited `0` (20 passed); shipping exited `0` (18 passed); PW-04/05/06/07/08 exited `0` (16/14/10/1/2 passed); handoff exited `0` (2 passed); `npm --prefix website run check` exited `0`; `npm --prefix website test` exited `1` (89 passed, five failed); after the bounded scroll fix, `npm --prefix website test -- tests/sketchbook.spec.ts` exited `0` (12 passed) and `npm --prefix website test -- --grep-invert "fixed-seed"` exited `0` (88 passed). Candidate-artifact `npm run test:cli-consumer` and manual animation review were not run after the website stop condition. -- Browser/manual artifacts: the initial Firefox actual/expected/diff localized all 99 changed pixels to the U+2605 star, rendered as Firefox's missing-glyph hexbox after earlier browser files. The focused test passed twice in fresh workers but failed in the full Firefox suite, proving worker-lifetime font fallback rather than runtime geometry or antialiasing. The distinct worker fix then passed full Firefox (53) and Chromium/Firefox (106) with unchanged snapshots. Website actual/expected/diff and traces remain under `website/test-results/` for four accepted Arch-versus-Ubuntu showcase differences. Still images are not manual animation review. -- Pre-existing/initial failures: initial `test:browser` exited `1` with the 99-pixel Firefox glyph fallback described above. The first `test:demos` retry was unavailable because a stale website preview owned port 4175; the identified preview process was stopped and the unchanged suite passed. An extra PW-02 investigation rerun failed its historical assertion that `./playwright` must not be public; independent review confirmed PW-03 intentionally superseded it, the config is not a current release command, and PW-05/PW-08 plus `test:package` own its lasting criteria. The first website release run had the four previously accepted host snapshots plus a smooth-scroll race; the bounded test fix removed the functional failure without changing product code or snapshots. -- Unverified items and reason: candidate artifact/CLI consumer, manual circle/underline/box animation, WebKit, Windows, Node 20, and Ubuntu snapshot identity remain unverified because RELEASE-01 requires stopping on the required website snapshot failures and this host cannot run the pinned Ubuntu renderer. -- Known limits carried forward: no WebKit injection claim; no cross-origin/opaque/detached frames or cross-document arrows; no shadow-root evidence, collision engine, or screen-reader certification; alternate build/deploy pipelines need equivalent output checks; the published registry `0.1.0` still lacks the candidate Playwright/reveal/box surface. -- Reviewer decision: BLOCKED. Independent review accepted the Firefox worker-isolation fix and classified PW-02 as superseded, but RELEASE-01 still requires a green pinned-Ubuntu website visual run before candidate packing and manual review may resume. +- Revision: `91572e51504f2d01704d798991262737bcffe89c` (`product`). +- Accepted artifact: `.release-artifacts/candidate/funsaized-stet-0.2.0.tgz`, 759,895 bytes, 321 files, zero required runtime dependencies, SHA-256 `2dd3b59d58bcc8440347aecd8de0f19ad837b20684bf564a677be996193989f7`. The local and CI tarballs are byte-identical. +- Required checks: local `npm run check`, `npm test` (335), `npm run test:agent` (64), `npm run test:templates` (40), `npm run test:package`, `npm run size`, `npm run test:browser` (106), `npm run test:patterns` (18), `npm run test:demos` (20), shipping (18), PW-04/05/06/07/08 (16/14/10/1/2), handoff (2), and candidate CLI consumer all exited `0`. Website check passed locally; all 88 non-snapshot tests passed locally. +- Pinned CI: library run `34778709320` passed check/unit/build/templates/package/size, Chromium/Firefox/WebKit browser and patterns, and candidate CLI consumers on Node 20/24 for Ubuntu/Windows. Website run `34776560130` passed all 94 tests. Reviewed Ubuntu box snapshots were added only because accepted box geometry had no pinned-CI baselines; Chromium/Firefox are byte-identical to Linux and WebKit differs only in antialiasing. +- Manual animation: `test-results/release01/reveal-start.png`, `reveal-mid.png`, and `reveal-final.png` show circle, underline, and box undrawn at dash offset `1`, coherent opaque partial strokes near `0.454`, and complete stable final marks with dash attributes removed and zero document animations. Independent visual review approved readability, continuity, and clipping. +- Initial failures retained: Firefox initially cached a U+2605 missing-glyph fallback across workers; a distinct pinned-font worker fixed the root cause without snapshot changes. A stale preview initially occupied port 4175. PW-02's historical no-public-export assertion is superseded by accepted PW-03 and is not a current release gate. Initial website CI exposed an inaccessible nested command scroller and automation-induced mobile scrolling; wrapping code and keyboard activation fixed them. No visual tolerance was raised. +- Known limits: no WebKit injection claim; no cross-origin/opaque/detached frames or cross-document arrows; no shadow-root evidence, collision engine, or screen-reader certification; alternate build/deploy pipelines need equivalent output checks. The registry's older `0.1.0` still lacks the accepted candidate Playwright/reveal/box surface. +- Reviewer decision: ACCEPT. Candidate identity, package surface, runtime checks, pinned CI matrix, fresh consumers, and manual animation satisfy RELEASE-01. RELEASE-02 must wait for publication of this exact `0.2.0` artifact. diff --git a/docs/delivery/evidence/RELEASE-02.md b/docs/delivery/evidence/RELEASE-02.md new file mode 100644 index 0000000..ffd38b0 --- /dev/null +++ b/docs/delivery/evidence/RELEASE-02.md @@ -0,0 +1,8 @@ +# RELEASE-02 evidence + +- Revision: `91572e51504f2d01704d798991262737bcffe89c` (`product`). +- State: BLOCKED before implementation. +- Accepted prerequisite: RELEASE-01 accepted `.release-artifacts/candidate/funsaized-stet-0.2.0.tgz` with SHA-256 `2dd3b59d58bcc8440347aecd8de0f19ad837b20684bf564a677be996193989f7`. +- Blocker: `@funsaized/stet@0.2.0` is not published. The stable website cannot install it from npm, while a `file:` path to the ignored candidate archive would be a checkout-only alias and would fail fresh-clone deployment. Published `0.1.0` lacks the candidate Playwright/reveal/box surface. +- Stop-condition result: no website dependency, lockfile, version label, capability claim, snippet, or template was changed to advertise an unavailable package. No publish, merge, or deployment occurred. +- Next step: after the requested heavy pre-merge review and explicit publication approval, publish the accepted archive, bind the website and fresh examples to exact `0.2.0`, run stable configuration checks and the full website matrix, then request independent RELEASE-02 acceptance. diff --git a/docs/delivery/tasks/RELEASE-01.md b/docs/delivery/tasks/RELEASE-01.md index 1f6aeda..698bde9 100644 --- a/docs/delivery/tasks/RELEASE-01.md +++ b/docs/delivery/tasks/RELEASE-01.md @@ -1,6 +1,6 @@ # RELEASE-01 — Run the full release gate ## Outcome -All required behavior is verified as an integrated package. **Parent:** H2. **State:** BLOCKED. +All required behavior is verified as an integrated package. **Parent:** H2. **State:** ACCEPTED. ## Prerequisites CONTRACT-05, FIX-01/02, MEASURE-01, CORE-01 through CORE-08, all AD tasks, BOX-01/02, PW-01 through PW-08, SHIP-01 through SHIP-03, AGENT-01 through diff --git a/docs/website-refactor-plan.md b/docs/website-refactor-plan.md new file mode 100644 index 0000000..2cadbf7 --- /dev/null +++ b/docs/website-refactor-plan.md @@ -0,0 +1,394 @@ +# Website refactor plan + +Status: implemented as a non-public 0.2.0 candidate preview on `product`. Public launch remains blocked on publication and registry binding. Original audit: September 19, 2026. Implementation notes are in section 8. + +## Recommendation + +Sell Stet as a way to explain a working interface with annotations attached to its elements. Show the result before explaining the machinery. Give developers two equally credible ways to author it: code and their existing coding agent. + +The expanded product supports three distinct outcomes: + +1. Keep an explanation in a product, interactive document, or demo. +2. Add temporary review annotations to a local or preview build. +3. Annotate a running page through Playwright and capture a handoff without editing application source. + +These are delivery choices, not separate audiences. A developer or an agent can use any of them. The website currently confuses author, use case, and delivery method, while describing almost everything as putting a circle around Save. + +Preserve the paper, ink, serif typography, and recognizable hand-drawn marks. Replace the motivational stationery copy and internal process language. This needs a product-story and information-architecture refactor, not a new visual identity or a new website stack. + +## 1. Audit scope and evidence + +### Sources + +- Public exports and dependencies: `package.json`, `src/index.ts`, framework entry points, `src/playwright/entry.ts`. +- Runtime behavior: `src/mount.ts`, `src/primitives.ts`, `src/rough.ts`, `style.css`. +- Agent tooling: `agent/cli.mjs`, `agent/catalog.mjs`, `agent/project.mjs`, `agent/install.mjs`, `agent/validate.mjs`, generated capabilities, schemas, templates, and skills. +- Product and release intent: `docs/product-direction.md`, `docs/delivery/backlog.md`, release evidence, `CHANGELOG.md`. +- Consumer guidance: `docs/reference.md`, `docs/playwright.md`, `docs/shipping.md`, agent and handoff documentation. +- Website: all six page components, route registry, framework data, showcase fixtures/artifacts, playground, homepage demo, relevant browser tests, SEO and analytics. + +Implementation and current contracts take precedence over historical strategy text. Some early sections of `product-direction.md` predate the now-implemented lifecycle API. + +### Browser inspection + +Used Playwright against the local Vite site at `http://localhost:4173`, with system Chromium. The browser tool's configured Chrome executable was unavailable, so the installed Playwright library launched `/usr/bin/chromium` instead. + +Captured all 16 public routes at desktop 1440×1000 and mobile 390×844: + +- `/`, `/agent-workflow`, `/playground`, `/docs`. +- `/docs/react`, `/docs/vue`, `/docs/svelte`, `/docs/angular`, `/docs/javascript`. +- `/use-cases` and all six scenario routes. + +Additional viewport captures cover all eight workflow stages, all six current playground tabs, workspace confirmation and four other perspectives, security interactions, buggy/fixed form submission, a published preview, tutorial progression, documentation expansion/empty state, and all five artifact panels. + +Local audit artifacts are in `/tmp/opencode/stet-site-audit/`, with `inventory.json` and `interactions.json`. Reproduction scripts are `/tmp/opencode/stet-site-audit.mjs` and `/tmp/opencode/stet-site-interactions.mjs`. These are temporary inspection artifacts, not committed fixtures. + +Full-page captures can omit annotations whose targets are outside the viewport because Stet culls them. Use the `scenario-*-live.png` and interaction viewport captures to judge the marks, not the initial full-page images alone. Captures used reduced motion for stable inspection; they do not verify animated reveal. + +Verified in this audit: + +- Save is disabled with an empty title, becomes available after typing, and submits locally. +- All workflow stages and current primitive tabs are reachable. +- Workspace confirmation opens; Escape returns focus to its trigger. +- Security controls update local demo state. +- The buggy form leaves focus on submit; the fixed form focuses the invalid field. +- Tutorial steps retain branch/environment values and produce the expected local output. +- Live documentation expands an event, filters it, and removes it when marked read. +- No page errors were recorded during the route capture pass. +- Home and agent workflow have document-level horizontal overflow at 390px; the other captured routes did not. + +This was a local UI audit, not a production-site audit, full regression run, accessibility certification, or fresh verification of all candidate runtime tests. + +## 2. What the product can actually do + +### Runtime and integration inventory + +| Surface | Current capability in the candidate | What to sell or demonstrate | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| `circle`, `box` | Attach hand-drawn boundaries to existing elements | Point to a control or group without replacing it | +| `underline`, `highlight` | Emphasize elements and wrapped text | Explain the relevant words instead of covering the whole screen | +| `arrow(from, to)` | Connect two elements; optional label, curvature, label offsets | Show which control affects which result | +| `sticky` | Plain text in an HTML overlay on sketched paper; preferred side and X/Y offsets | Put an explanation beside the thing it describes | +| `mark` | `right` check or `wrong` cross | Mark a reviewed result; make verdicts distinguishable by shape | +| `StetHandle` | `show()`, `hide()`, `replay()`, `refresh()`, `resketch(seed?)`, `destroy()` | Control when an explanation appears, repeat it, and clean it up | +| Visibility and reveal | `visible`, `animate`, `animationDuration`, `animationDelay`; awaitable `finished`/`cancelled` results | Draw attention at a chosen moment instead of leaving everything on screen | +| Styling and geometry | `stroke`, `fill`, `width`, `padding`, `roughness`, `seed`; CSS theme tokens | Fit the product's visual language; reproduce the same sketch | +| Ambient motion | `boil`, `resketchOnHover`; reduced-motion behavior | Optional liveliness, separate from finite reveal | +| Accessible meaning | `description`, sticky text and arrow labels linked through owned `aria-describedby` IDs | Explain meaningful marks while preserving native semantics | +| Placement and lifecycle | Resize/scroll/font handling, manual refresh, offscreen culling, cleanup | Keep marks attached as the supported layout changes | +| React | `Circle`, `Box`, `Underline`, `Highlight`, `Arrow`, `Sticky`, `Mark`; refs and `onHandle` | Add annotations in an existing component, not a replacement UI | +| Vue | Corresponding `vStet*` directives and handle callbacks | Bind annotations through Vue's element lifecycle | +| Svelte | Corresponding element actions and handle callbacks | Attach/update/destroy with the element | +| Angular | Corresponding standalone directives and `stetOnHandle` | Use Angular lifecycle and client rendering | +| Vanilla | Named functions returning handles | Use the same library without a framework | +| Playwright | `createStet(pageOrFrame)`, six remote primitive methods, async handles, `dispose()`, typed errors | Annotate a running page for a screenshot or review without source edits | +| Packaging | ESM, TypeScript definitions, separate stylesheet, optional adapter/Playwright/agent entries, zero required runtime dependencies, MIT | Small adoption commitment; no hosted service or model API required | + +Core options are described by `StetOptions`; `ArrowOptions` adds label/curvature/label offsets, and `StickyOptions` requires `text` and adds optional side/offsets. There is no public general-purpose update method, scene graph, preset engine, or group/tour orchestration API. + +Important asymmetries must survive the copy rewrite: + +- There are **seven core/framework primitives**, but the current public Playwright session has **six**. `box` is absent from both `StetPrimitive` and `StetSession` in `src/playwright/entry.ts`. Do not advertise it through injection unless a separately scoped API change adds and tests it. +- All core handles expose visibility/lifecycle methods. Finite reveal is supported only for **circle, underline, and box**. Unsupported reveal requests throw; they are not silently animated. With no box method, Playwright injection supports reveal for circle and underline only. +- `show()`/`replay()` completion is not the same thing as arbitrary sleeps or ambient boil. `hide()` is immediate. +- Current framework option updates preserve the handle where possible and do not implicitly replay. The website's old advice that all option changes reattach is obsolete for the candidate. Vanilla still has no public general options-update API. +- Core browser use and Playwright injection have different support boundaries. Injection claims cover Chromium/Firefox and supported same-origin frames, not WebKit, cross-origin frames, or cross-document arrows. Navigation invalidates a session; locators must resolve to one connected element. +- Marks do not guarantee global collision avoidance, obstacle routing, top-layer compatibility, or arbitrary transformed/zoomed root layouts. Accessibility behavior is implemented and tested, not universally certified. + +### Agent and build-time surface + +| Surface | Capability | User value | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `stet inspect --project . --json` | Installed-version capabilities plus bounded, read-only project/framework evidence | The agent can use the version actually in the project | +| `stet schema ...` | Annotation-plan and capability schemas | A machine-readable contract rather than guessed APIs | +| `stet validate ...` / programmatic `validatePlan` | Shape and semantic validation with errors/warnings | Catch unsupported plans before changing source | +| `stet snippet ...` | Seven primitive templates across five frameworks; lifecycle templates across five frameworks | Framework-correct starting points, including cleanup | +| `stet agent init/update --tool ...` | Install/update project skills for Claude, Cursor, OpenCode, Codex; conflict-aware recovery | Fit the coding tool already in use | +| Four skills | Base workflow plus explain, review, showcase judgment | Help choose useful marks, targets, and delivery method | +| Shipping recipes | App-owned production/review modules; Vite `off`, `preview`, `local` policy with output inspection | Keep intentional annotations and exclude temporary review material deliberately | + +Plans guide authoring; the drawing runtime does not execute them as selectors. Stet does not supply the model, decide whether a claim is true, run QA, automatically rewrite source, or host review conversations. Put this boundary next to the relevant workflow, not in every sales paragraph. + +Shipping policies are recipes for application-owned code, not a built-in Stet mode API. Hiding marks is not proof that review strings, CSS, chunks, or source-map content are absent from a build. + +### The underlying advantage + +The same element-attached annotation vocabulary works in a durable component, an interactive explanation, an agent-authored review, and a browser-captured artifact. The person receiving it can see where to look; the person creating it can keep using normal source control, framework lifecycle, and browser tooling. + +That is a stronger proposition than either "six cute marks" or "a coding-agent workflow." Reveal, reproducible geometry, framework ownership, accessible descriptions, and explicit delivery choices make the proposition practical. They are proof points, not seven competing hero messages. + +### Release truth + +The repository is a 0.2.0 candidate; `website/package.json` pins 0.1.0. Local release evidence records candidate verification accepted and public publication/site binding (`RELEASE-02`) blocked. This audit did not independently query the registry. + +Prepare the redesign on a clearly identified preview using the candidate if needed, but do not deploy stable install commands or demonstrations for an unavailable package. Before launch, confirm registry availability, bind the site to the published exact version, and retest from that dependency. No silent checkout alias on the public site. + +Future highlight/arrow/sticky reveal, ordered groups, extra shapes, a Vite plugin, a hosted service, and an MCP server are not promises for this refactor. + +## 3. Audiences and buying reasons + +The conversion is adoption, not a checkout. The site needs to get a developer to try an example, copy a useful integration, and use Stet again. + +| Audience | Job and reason to care | Best proof | Next action | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------ | +| Frontend, design-system, and documentation developers | Explain states and controls in examples people can still use | Live documentation, conditional form state, framework lifecycle | Copy the matching framework example | +| Developers using coding agents | Review what the agent changed without translating a long prose report into locations on screen | Annotated result, short request, inspectable source and evidence | Install skills for their tool; copy the request | +| Playwright/QA/engineering teams | Point to a reproduced issue in a captured browser state without modifying the app | Locator-based annotation, screenshot, cleanup, separate behavior assertion | Copy and run the Playwright recipe | +| Demo and tutorial authors | Direct attention at the right moment while preserving input state | Show/hide/replay and guided steps | Adapt the demo source | +| Product and design reviewers | Understand a behavior or finding in context | Billing amount, empty-search recovery, before/fixed focus | Open/share the example; work with the implementing developer | + +Prioritize the first three in the homepage story. Treat product/design as recipients and collaborators, not as if Stet already provides a no-code review application. Storybook/MDX and presentation usage are integration recipes to prove, not native integrations to badge without examples. + +## 4. Screen-by-screen refactor + +### Home: `/` + +Observed sequence: credible live Save demo; oversized and repetitive installation/process section; use-case teaser; twelve decorative specimens; sentimental closing CTA to GitHub. + +The headline "Explain the UI. Keep it interactive." already expresses a useful outcome. The paragraph narrows the product to one control's state, and the sections below repeatedly discuss "first success," "advertised 0.1.0," and stopping unsupported requests. Those are implementation instructions leaking into marketing. + +The sketchbook has visual character, but its stars, fortunes, encouragement, and "small victories" occupy substantial space without proving why a developer would install the product. "Not everything needs to be perfect" actively weakens the engineering story. + +Proposed order: + +1. Retain the direct headline or test "Point to what matters in your UI." Use one concrete supporting paragraph and a working demo immediately. +2. Keep Save as the default state demo. Add concise, explicit demo choices for a review finding and a temporary capture. Use a recorded output for Node-side Playwright, clearly labeled, rather than pretending to execute Playwright in the browser. +3. Put "Try the examples" and "Get started" beside the exact install command. The primary action should reveal useful output, not jump into a wall of protocol text. +4. Show the two authoring paths as short code/request cards. Move the full CLI process to the existing agent-workflow page. +5. Explain source, preview, and capture delivery with one example each. +6. Show a compact capability strip: seven marks, show/hide/replay, framework adapters, Playwright. Link to proof rather than listing every option. +7. Close with an install/framework/agent choice. GitHub remains secondary. + +Remove the twelve-item sketchbook from the main conversion path. Preserve a few relevant specimens in the playground; do not add another gallery route unless the material earns one. + +Suggested supporting copy: + +> Add hand-drawn circles, arrows, highlights, and notes to real elements. Use them in live docs and product demos, or annotate a running page with Playwright for a review. Write the code yourself or use your coding agent. + +This is launch copy for the version that actually includes injection, not replacement copy to deploy against 0.1.0. + +### Agent workflow: `/agent-workflow` + +Observed sequence: abstract headline; long explanation and disclaimers; scripted conversation; eight stages; annotations unavailable until Implement; technical details open by default. At the initial desktop position the working fixture starts near the bottom of the viewport. On mobile, most of the first screen is explanation rather than output. + +Refactor to **"Give your agent a way to show what changed."** Lead with the annotated result and one short request. Keep a visible "Example walkthrough" label so nobody mistakes it for a live model session. + +Present three reader-facing steps: + +1. Set up your tool: select Claude, Cursor, OpenCode, or Codex; copy the exact install/init commands. +2. Ask for the result: choose an explanation that stays in source or a captured handoff; show the corresponding request and artifact. +3. Inspect the handoff: working UI or captured image, what changed, what was checked, and any remaining work. + +Retain inspect, plan, validate, snippet, implementation, and verification details underneath, in a compact expandable technical walkthrough. Do not delete rigor; stop making a visitor perform eight clicks to discover its result. + +Use the current security or form-focus example rather than making a single green circle carry the entire handoff claim. Show actual commands/output or clearly labeled sample output. Test results must remain attributed to the tool that ran them. + +### Playground: `/playground` + +Observed: functional primitive tabs, seeded resketch, colors/roughness, ambient-motion switch, collapsed placement controls, copyable vanilla code. Most of the canvas surrounds "Look at you go." The arrow connects "a little nudge" to that phrase, so it demonstrates a shape but not a useful relationship. + +Refactor to **"Try the marks. Copy the code."** + +- Add core `box`; cover all seven primitives without implying Playwright parity. +- Replace the generic specimen with a small working form/card. For arrows, connect a choice to its computed result; for sticky notes, explain a constraint; for checks/crosses, identify a reviewed outcome. +- Keep the simple style controls. Separate **Reveal** from **Ambient motion**. Only offer reveal on supported primitives, and make reduced-motion behavior visible. +- Add Show, Hide, Replay, and Resketch controls that exercise the actual handles. Replay must retain geometry; resketch changes it. +- Expose sticky and arrow-label offsets, padding, width, seed, descriptions, and primitive-specific fields through progressive disclosure. +- Add framework output selection using the existing canonical templates. A snippet must reproduce the chosen options and include CSS, target attachment, and cleanup. +- Put readable code below or beside the demo with enough width. Current code is squeezed into a narrow settings column even on desktop. +- Link to the separate Playwright recipe; do not build a browser IDE, arbitrary page injector, or hosted runner. + +### Docs overview: `/docs` + +Observed: "Your first little mark" opens a long page repeating the same Save example and lifetime warning across write/agent/API sections. Full reference and essential delivery guidance send visitors to GitHub. The options table and motion section omit the candidate lifecycle surface. + +Refactor to **"Add annotations to your app."** Start with a short choice: **In your app / With your agent / With Playwright**. These are entry points, not mandatory sequential reading. + +Provide installation, CSS, one working annotation, and cleanup before background explanation. Then organize the material as: + +- Framework setup. +- Primitives and options. +- Visibility, reveal, replay, and reduced motion. +- Styling and placement. +- Agent setup and plan validation. +- Playwright capture and session lifecycle. +- Production, preview, and local-only shipping. +- Accessibility and support limits. + +Use the existing Markdown guides as source material. Render the important consumer guidance on-site and keep source links. Add focused routes only where the content warrants them, particularly `/docs/playwright` and `/docs/shipping`; do not turn every paragraph into a page. + +Include a primitive-by-delivery/reveal support table. A reader must not infer that every option on `StetOptions` is meaningful for every primitive. Replace stale framework reattachment advice. Replace "Back to the pencil case" with a literal navigation label. + +### Framework guides: all five routes + +The shared layout is useful and should stay. The current pages provide real generated snippets, but move quickly from a first mark to large imperative multi-handle templates. Long inline options require horizontal code scrolling; the main first example should not require that at desktop width. + +| Route | Preserve | Change | +| ------------------ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/docs/react` | Refs, client boundary, component-based first example | Show declarative `visible` and `onHandle` before the advanced multi-target lifecycle pattern; retain StrictMode/cleanup guidance | +| `/docs/vue` | Native directive entry and source ownership | Show reactive option/visibility changes and callback use; format inline objects across readable lines | +| `/docs/svelte` | Element actions and cleanup | Demonstrate updating an action's options without remounting the app's control; distinguish action usage from advanced multi-handle effects | +| `/docs/angular` | Standalone directives and global stylesheet instruction | Lead with directive visibility/handle use; move the much longer imperative example below the first result; preserve SSR/client lifecycle guidance | +| `/docs/javascript` | Direct functions and explicit ownership | Provide actual JavaScript for the default example, with optional TypeScript; show all six handle methods and when manual refresh is needed | + +On all five: publish the supported framework versions, show the installed package version consistently, include one tiny stateful demo, and keep advanced cleanup/target-replacement examples available. Do not silently equate a generic first-primitive framework template with a translation of an entire scenario. + +### Examples index: `/use-cases` + +The index currently opens the workspace-deletion example instead of helping a visitor choose. "Agentic" versus "Developer" falsely separates jobs that either author can perform. + +Make the index a concise visual chooser grouped by outcome: **Explain a live interface / Review a change / Teach or demonstrate / Capture a handoff**. Use existing fixtures and small real previews. Preserve current URLs; rename visible navigation to "Examples" if adopted. + +Scenario pages should place a short title, one concrete instruction, the live result, and its annotation toggle before deep explanation. Use short visible titles; reserve SEO wording for metadata. Lead the artifact panel with usable code, with Plan and Verification available as separate views. + +### Each existing scenario + +| Route | Observed proof and problem | Refactor | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/use-cases/workspace-deletion` | Confirmation and Escape work. Product/UX/Engineering/QE tabs unexpectedly replace the entire app while the heading still says dangerous action | Keep the destructive-action example focused. Move billing, search recovery, and import findings into clearly named secondary examples or an explicitly titled team-review section. Do not present unrelated apps as perspectives on one button | +| `/use-cases/security-handoff` | Password threshold, session revoke, and conditional 2FA detail are interactive. The page title is a long SEO phrase; annotations identify controls more than they explain a change | Title: "Review a security-settings handoff." Show a concise changed/checked/remaining summary next to the relevant control; retain local-only/authentication boundary | +| `/use-cases/form-review` | Strongest current review proof: Buggy and Fixed genuinely differ in focus behavior | Title: "Show the bug. Check the fix." Move the reproduction instruction above the form; report the focus observation visibly so the value is not dependent on noticing a colored ring | +| `/use-cases/feature-showcase` | A real release form works, but three static marks are sold as three features | Title: "Walk through a release preview." Use explicit show/hide/replay for supported marks to demonstrate attention over time. Keep publishing and validation owned by the app | +| `/use-cases/guided-tutorial` | Branch/environment choices survive progression; the environment highlight covers a large fieldset | Title: "Guide the next step without resetting the form." Use the new box where appropriate; show or replay supported strokes. State that this app implements the steps, not a built-in tour engine | +| `/use-cases/live-documentation` | Filter, expansion, and read state work; disappearing targets clean up their marks | Title: "Let readers try the interface." Make the expand/filter/clear sequence explicit. Show the small state-to-annotation snippet; promote this example higher for docs authors | + +Add one genuinely missing example: **"Annotate a page without changing its source."** Show a small `createStet(page)` recipe, its generated screenshot, the tested capture conditions, and `dispose()` in `finally`. The browser displays the artifact; the user's Playwright process performs injection and capture. Reuse `examples/playwright` and the handoff fixtures rather than inventing another integration. State the caller-owned `@playwright/test` peer range (`>=1.63 <2`), Chromium/Firefox support, single connected-element targeting, and same-origin script/style loading requirement. CSP remains active and can reject loading with `CSP_BLOCKED`. + +### Shared navigation, layout, and mobile + +- Keep the desktop shell; use a compact, keyboard-accessible mobile menu. Current 390px captures squeeze the logo and navigation together, with labels wrapping beside the logo. +- Fix intrinsic-width overflow in the homepage `principles-intro`/code grid and workflow explanation/code grid. Measured document widths were about 508px and 494px on a 390px viewport. Contain code overflow inside its panel instead of hiding overflow on the entire page. +- Use readable code with local horizontal scrolling where necessary; format teaching examples to avoid needless scrolling. +- Reduce duplicate headings, repeated prompts, oversized intro spacing, and empty code-card space. Keep the demo and next action close together. +- Use legible small labels and touch targets. Test header, tabs, copy feedback, accordions, and native form focus with keyboard and touch-size viewports. +- Replace the footer's motivational paragraph with package/version, docs, examples, GitHub, and MIT information. One restrained handwritten brand note is enough. + +## 5. Copy rules and replacements + +The problem is not that every playful word is wrong. The problem is that the copy describes personality or internal process where a visitor needs a reason to adopt the library. + +| Current copy | Proposed direction | +| ----------------------------------------------------- | ------------------------------------------------------------------------- | +| "FITS RIGHT INTO YOUR WORLD" / "same ink, any canvas" | "JavaScript, React, Vue, Svelte, and Angular" | +| "02 / TWO EQUAL STARTS" | "Write the code or ask your agent" | +| "First success for both authoring paths..." | Give the instruction itself; remove the process label | +| "From a sentence to something you can inspect" | "Give your agent a way to show what changed" | +| "Six ways to leave a mark" | "Try the marks. Copy the code." | +| "Your first little mark" | "Add annotations to your app" | +| "A thoughtful kind of playful" | "Motion and accessibility" | +| "Not everything needs to be perfect" | "Add Stet to your next example" | +| "Next action: try Save, then install" | Put the demo instruction beside Save and label the install action clearly | + +Rules: + +1. State a job, mechanism, or result. Delete sentences that do none of those. +2. Use concrete nouns: button, form, code, screenshot, test, annotation. Avoid repeated "intent," "contract," "inspectable," and "shared context" as sales language. +3. Attribute work accurately: the app handles validation; the agent writes code; Playwright drives/captures the browser; Stet draws and manages annotations. +4. Keep caveats at the decision they qualify. Do not remove supported-version, accessibility, or shipping limitations to sound more confident. +5. Screenshots are an output and sometimes the correct tool. Stop framing them as the enemy now that capture is a supported workflow. +6. Use real examples instead of invented testimonials, unsupported adoption numbers, or speed claims. Publish measured package sizes only with the matching version and measurement scope. + +## 6. Implementation sequence + +### Phase 1: establish version and claim consistency (P0) + +Files: website package/lockfile, `constants.ts`, `showcase/canonical.ts`, `frameworks.ts`, `seo.ts`, layout/version labels, relevant tests. + +- Separate two gates. For a non-public preview, bind to the accepted candidate tarball and label the version; this is where new copy, demos, and tests can be developed before publication. For launch, confirm registry publication, replace the preview binding with the exact registry version, reinstall cleanly, retest, and only then deploy. Do not weaken the existing stable-version tests while leaving the runtime on 0.1.0. +- Derive installation/version labels from the same installed package metadata already used by canonical snippets; remove repeated hardcoded version strings. +- Inventory core versus Playwright capabilities explicitly. Document the six-method Playwright surface already specified in the reference; this website work does not expand it. +- Replace test assertions that forbid candidate features with assertions for the supported release. Keep negative assertions for unsupported reveal and other real limits. +- Bind source/reference links to the appropriate release where possible; do not route candidate-specific promises to unrelated `master` content. + +Preview acceptance: runtime, CSS, templates, capabilities, and demonstrations agree with the identified candidate. Launch acceptance: copied commands, visible version, metadata, and examples agree with the registry package, and a clean consumer installation can run them. Launch acceptance remains blocked until publication; preview implementation need not wait. + +### Phase 2: fix the shell and sell one useful result (P1) + +Files: `Layout.tsx`, `Home.tsx`, `HeroDemo.tsx`, `Sketchbook.tsx`, shared styles, `constants.ts`. + +- Fix mobile navigation and both known overflow defects. +- Rewrite/reorder the homepage as described above; shorten the two authoring paths. +- Move the decorative gallery out of the homepage's main path. +- Add a small supported reveal/visibility interaction to the hero without replacing its form behavior. + +Acceptance: a visitor can identify what Stet does, use a real demo, and find installation without reading the agent protocol. At 390px there is no page-level horizontal scroll; the demo remains usable by keyboard. + +### Phase 3: expose the expanded API in playground and docs (P1) + +Files: `Playground.tsx`, `MarkGlyph.tsx`, `constants.ts`, canonical/snippet helpers, `Docs.tsx`, `FrameworkDocs.tsx`, `frameworks.ts`, route metadata. + +- Add box, lifecycle controls, supported reveal, offsets, and framework snippets. +- Split getting started from detailed reference and publish Playwright/shipping guidance on-site. +- Update each adapter guide to current lifecycle behavior and format teaching examples. + +Implement the smallest usable playground increment first: box, Show/Hide/Replay/Resketch, supported reveal controls, and matching copyable code. Reuse existing advanced controls and template generation for the rest; do not build a second API reference or generic schema-driven editor. New documentation routes are optional if clear sections in the existing docs page remain readable. + +Acceptance: every visible control has a supported API counterpart; copying a snippet reproduces its options; unsupported reveal is not offered; cleanup and reduced motion work; all five framework examples compile. + +### Phase 4: turn examples into adoption paths (P1) + +Files: `UseCases.tsx`, `showcase/scenarios.ts`, existing fixture components/options, `Artifacts.tsx`, `showcase.css`. + +- Replace the index's implicit default scenario with an outcome-based chooser. +- Rewrite all six scenarios, preserve their useful interactions, and remove the misleading role grouping. +- Make actual source/code the easiest artifact to reach; retain plans, verification, and full-source disclosure. +- Add the Playwright capture example using existing packed-package recipes and generated output. + +Acceptance: every example states one job, shows a relevant result, offers the right code, and identifies its delivery mode. Existing reproduction/focus/state-preservation checks still pass. + +### Phase 5: rebuild the agent page around the handoff (P1) + +Files: `AgentWorkflow.tsx`, setup constants, artifacts and shared example components. + +- Lead with the result, then setup/request/inspection. +- Support all four tool setup commands and both source/capture examples. +- Keep the technical sequence available without making it the primary interaction. + +Acceptance: visitors see the annotated output before progressing through steps; no live-model claim is implied; copied requests and commands match the installed version; source and capture workflows are not confused. + +### Phase 6: verify launch and learn from adoption (P1) + +Files: website tests, SEO/prerender scripts, metadata/social assets, `analytics.ts`, website README. + +- Preserve current URLs and redirects if any route changes. Update titles, descriptions, structured data, sitemap, social images, and crawlable prerendered content. +- Run website build/type checks and browser tests; compile canonical templates and validate plans against the launch artifact. +- Recapture all routes at desktop/mobile sizes, plus meaningful interaction states with targets in view. +- Check keyboard traversal, focus, reduced motion, copy feedback, annotation toggles, and overlay cleanup; run the existing axe checks and manual inspection. +- Verify Playwright recipes in their claimed browsers and candidate package tests before making public claims about those paths. +- Use the existing `copy_install` and `select_framework` analytics as the initial baseline. If they cannot answer where adoption stalls, add only the needed example/snippet/setup event to the existing helper. Never send input values, prompts, copied source, or page content. + +Commands available in this repository include `npm --prefix website run check`, `npm --prefix website test`, `npm run test:templates`, and the relevant package/Playwright/shipping checks. Run the narrow checks during each phase, then the full release-required set before launch. Do not replace behavior assertions with screenshot approval. + +Success measures: more visitors reach a working example and copy a relevant integration; short user sessions confirm they understand source versus capture; follow-up users report using Stet in a real project again. Copy clicks are an adoption signal, not proof of installation or retention. Establish a baseline before claiming improvement. + +## 7. Scope boundaries + +Keep React, TanStack Router, Vite, the existing canonical-template pipeline, and the reusable fixtures. No CMS migration, new design system, hosted agent execution, browser editor, analytics platform, or arbitrary-URL service is needed. + +Do not make unsupported runtime additions a prerequisite for a website refresh. The current candidate already supports a much stronger story than the site tells. Correctly expose that surface, make the proof easier to reach, and give each audience a short path to using it. + +## 8. Implementation notes + +- Home now leads with the working Save example and offers review/capture choices. The decorative gallery is no longer on the homepage. Authoring and delivery are separate sections. +- The agent page shows the security handoff first, followed by tool setup, source/capture requests, and inspection. Technical details remain available without an eight-stage prerequisite. +- The playground includes box, real handle controls, supported reveal duration/delay, ambient motion, placement offsets, descriptions, and five framework outputs. Resketch updates the copied seed. Generated options are escaped for each framework and compiled with adversarial string inputs. +- Docs cover the actual support matrix, lifecycle, Playwright, shipping, and accessibility. Framework teaching snippets compile; vanilla starts with actual JavaScript. Playwright and shipping remain sections in the existing docs page rather than new routes. +- The examples index uses outcome groups and recorded thumbnails of working fixtures. Existing URLs and native controls remain. The new `/use-cases/playwright-handoff` displays a real injected capture and its reproduction recipe. +- Mobile navigation is a keyboard-accessible disclosure. The two measured overflow defects are fixed. Metadata, social artwork, source links, and regression tests reflect the new content. +- The website vendors the exact accepted RELEASE-01 tarball, byte-for-byte (SHA-256 recorded in `website/vendor/README.md`), so clean installs do not depend on an ignored release directory. This is preview-only and adds no new runtime dependency. Remove it when the site is pinned to the published package. +- Candidate previews are visibly labeled, emit noindex metadata, an empty sitemap, and crawl disallow rules. The ordinary public build rejects a candidate binding. The quality CI job explicitly builds a preview; no publication or deployment was performed. + +Verification commands and launch instructions are in `website/README.md`. Local before/after browser captures are in `/tmp/opencode/stet-site-audit/` and `/tmp/opencode/stet-site-refactor/`. These temporary files are inspection evidence, not a durable test fixture; reviewed visual baselines live in `website/tests/showcase.spec.ts-snapshots/`. + +Verified locally: + +- Clean `npm ci --ignore-scripts` in `website/` succeeds with the vendored artifact. +- `npm --prefix website run check:preview` passes lint, formatting, snippet compilation, TypeScript, client/SSR builds, prerendering, and SEO integrity. +- `npm --prefix website test -- --workers=4` passes all 126 desktop/mobile checks without updating snapshots, including native behavior, keyboard navigation, reduced motion, axe checks, and six reviewed visual baselines. +- All 35 configured playground snippets and four framework teaching components compile; all 40 package templates/lifecycle examples also pass `npm run test:templates`. +- `npm test -- tests/agent/showcase.test.ts` validates the bundled plans and source references. +- The capture script verifies injection, disposal, and native behavior in Chromium and Firefox. Chromium generated the recorded image. +- All 17 routes were recaptured at desktop and mobile sizes with no document overflow or recorded page errors. +- An independent process check confirms that an ordinary public build rejects the candidate dependency. An independent diff review found no remaining material issues after fixes. + +Publication, production-origin verification, Search Console work, and adoption measurements still require a real launch. No conversion or retention improvement is claimed from this implementation. diff --git a/website/README.md b/website/README.md index cb97761..f4ee3fd 100644 --- a/website/README.md +++ b/website/README.md @@ -2,10 +2,17 @@ The Stet product website, built with Vite, React, TypeScript, and TanStack Router. Public routes are rendered to HTML at build time and hydrated in the browser. -The demonstrations use the website's pinned `@funsaized/stet@0.1.0` dependency, -including its runtime, CSS, capability JSON, and canonical framework templates. +The demonstrations use the accepted `@funsaized/stet@0.2.0` candidate in +`vendor/funsaized-stet-0.2.0.tgz`, including its runtime, CSS, capability JSON, +and canonical framework templates. See [candidate provenance](vendor/README.md). The browser does not import CLI or validation code. +This is a **non-public preview**. Version 0.2.0 was unavailable on npm when this +refactor was implemented. The site labels the candidate, emits `noindex` metadata, +disallows crawling, and emits an empty sitemap. A normal `npm run build` rejects +the local artifact dependency. Do not enable the preview override for a public +deployment or publish the package as part of building the site. + ## Development Use Node.js 22.12+ and npm to develop or build this website. This is not the Stet @@ -20,20 +27,23 @@ npm --prefix website run dev Start at `/use-cases`. Direct links are: -- `/use-cases/workspace-deletion`: Explain, Product, UX, Engineering and QE views. +- `/use-cases/workspace-deletion`: exact-name confirmation and separate billing/search/import reviews. - `/use-cases/security-handoff`: three implemented local security controls. - `/use-cases/form-review`: compare buggy and fixed focus handling, with the fix annotated in green. - `/use-cases/feature-showcase`: feature composition over a release form. - `/use-cases/guided-tutorial`: a dedicated deployment form with branch and environment choices. - `/use-cases/live-documentation`: a working activity inbox with filters, expandable details and read state. -- `/agent-workflow`: deterministic walkthrough of the shipped authoring pipeline. -- `/playground`: published primitive options, seed, reset and copy. +- `/use-cases/playwright-handoff`: recorded, source-free browser capture and its recipe. +- `/agent-workflow`: annotated result, tool setup, and source/capture authoring paths. +- `/playground`: seven primitives, visibility/reveal, placement, and five framework snippets. - `/docs`: integration instructions and canonical framework examples. The homepage is `/`. Fonts are bundled with the app; there are no external font requests or backend services. Google Analytics measurement ID `G-NECYZ55K84` is enabled in `index.html` and loads asynchronously. No environment -variables are needed. The library itself contains no analytics; the product website +variables are needed for development. Preview builds require the explicit +`STET_CANDIDATE_PREVIEW=1` flag (set by `build:preview` and `check:preview`). +The library itself contains no analytics; the product website uses GA. Successful install-command copies emit `copy_install`; framework-tab choices emit `select_framework` with only the framework name. These conversion signals run only on the canonical production origin and never include code, @@ -49,21 +59,26 @@ npm run lint # Oxlint: correctness, React hooks, and accessibility rule npm run lint:fix # Apply safe lint fixes npm run format # Format with Oxfmt npm run format:check # Check formatting without changing files -npm run check # Lint, formatting, TypeScript, and production build +npm run test:snippets # Compile 35 configured snippets and framework teaching examples +npm run check:preview # Lint, formatting, snippets, TypeScript, static preview and SEO npx playwright install chromium -npm run test:e2e # Build and test the production site +npm run build:preview +npm test # Test the built preview ``` Browser checks cover desktop and mobile interactions, every annotation primitive, framework snippets, clipboard operations, route cleanup, direct documentation loads, keyboard tabs, reduced motion, overflow, and automated WCAG AA audits. -Sketchbook tests also cover regeneration, palette changes, fixed list and page length, -retained control state, pause controls, and reaching the footer. +Tests also cover mobile navigation, source/capture choices, the real form-focus +defect and its fix, tutorial state preservation, and the support matrix. Automated accessibility checks supplement manual keyboard and visual inspection; they are not a complete accessibility certification. ## Vercel +The settings below apply **after publication and registry binding**. While the +candidate dependency is present, the public build intentionally fails. + Import `funsaized/stet` into Vercel and configure: | Setting | Value | @@ -107,10 +122,10 @@ loaders to serialize; the browser loads its matched route before hydration and preserves the router's SSR boundary structure. If loaders are introduced, use TanStack's data dehydration/hydration protocol as well. -The homepage initially uses a deterministic sketchbook edition for hydration, -then restores per-visit randomness after mount. Cards retain their fixed grid size. Build-time rendering never mounts annotation effects; browser overlays attach to -the real controls after hydration. +the real controls after hydration. Examples use fixed seeds. During screenshot +capture, scroll annotation targets into view so offscreen culling does not hide +their marks. `npm run test:seo` validates built HTML, unique metadata, canonical URLs, internal route links, structured data and exact sitemap coverage. This also runs during @@ -133,35 +148,44 @@ measurement commands and the dated before/after summary. The social card's editable source is `public/social.svg`. To regenerate the PNG after changing it, install Playwright Chromium and run `npm run social`. -When releasing a new Stet version, update the exact dependency and lockfile, -installation constant, version labels, snippets where needed, and documentation. -Run the complete checks before shipping. +After the maintainer publishes 0.2.0: + +1. Confirm `npm view @funsaized/stet@0.2.0 version` succeeds. +2. Run `npm install --save-exact @funsaized/stet@0.2.0` in `website/`. +3. Remove the vendored candidate and its root `.gitignore` exception. +4. Run `npm ci`, `npm run check`, and `npm test` without the preview override. +5. Review the package/source links, re-create captured assets, and verify the + production origin and indexing settings before deploying. -## Fixed sketchbook +Version labels and copied installation commands derive from the installed +capabilities and dependency binding. Do not change only the label or command. -The homepage combines benefits and installation into one section, followed by a -fixed collection of 12 distinct interactive examples. Each visit creates a random -seed and palette. **Shuffle everything** explicitly replaces the collection and -changes its arrangement, ink geometry, boil variation, and colors. +## Recorded Playwright handoff -All 12 cards stay mounted. Scrolling never adds, removes, reorders, or replaces -cards, and their control state persists as visitors move away and return. The -responsive CSS grid has a fixed row height; the footer follows the final row. -IntersectionObserver only triggers each card's one-time entrance animation. -Stet handles refresh during that 650ms entrance, then track scrolling normally. +Run `npm run dev -- --port 4173` in one terminal, then in another: + +```sh +npx playwright install chromium firefox +npm run capture:handoff +STET_CAPTURE_BROWSER=firefox npm run capture:handoff +npm run capture:examples +``` -Six new examples appear in every edition: a plant to water, a fortune to open, -mood faces, star ratings, an imaginary record player, and a progress card. -They are mixed with six other examples chosen without duplicates. -The record player is an explicitly imaginary interaction and plays no audio. +The script exercises the local security controls, switches off source-authored +marks, injects a seeded circle through the installed optional Playwright entry, +awaits completion, captures the fixture, disposes the session, and verifies the +native revoke control still works. Chromium writes +`public/images/playwright-handoff.png`; Firefox verifies the same path without +overwriting the image. `STET_SITE_URL` can select another local preview origin. +The website labels the image as recorded output; no Playwright or model runs in +the visitor's browser. -**Pause ink** stops boil and future entrance motion; reduced-motion preferences -apply automatically. **To the bottom** is a normal anchor to the collection's end. -There is no loading trigger, endless mode, or add-more control. +`capture:examples` regenerates the six static thumbnails used by the examples +index from their working fixtures. Review these images when changing a fixture. ## Showcase contracts and verification -`src/showcase/scenarios.ts` owns demo-only intent, perspective and plan metadata. +`src/showcase/scenarios.ts` owns outcomes, instructions, and plan metadata. Fixtures mount explicit React adapters with shared option objects. Plans are never interpreted at runtime. The source panel imports those exact component files as text. The Frameworks panel reads generated canonical templates, not another API @@ -181,9 +205,11 @@ canonical templates, builds the static site and runs browser checks. It uploads deep links without a backend. No hosting project or production deployment is created by these configuration files. -The playground's pixel nudges are unreleased checkout features; published 0.1.0 -users should query their installed capabilities and omit these options. Use-case -plans use existing 0.1.0 primitives/options. +The candidate includes seven core/framework primitives, while the Playwright +session exposes six (no box). Reveal is limited to circle, underline, and box in +the app; injection supports circle/underline reveal only. The support matrix and +playground controls preserve those boundaries. Do not copy candidate examples +into a 0.1.0 application. Website screenshot baselines use the Ubuntu 24.04 CI runner and the lockfile-pinned Playwright browser. Other Linux distributions can render text differently even diff --git a/website/package-lock.json b/website/package-lock.json index 3f3c69e..17d93bf 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@fontsource/caveat": "^5.2.8", "@fontsource/dm-sans": "^5.2.8", - "@funsaized/stet": "0.1.0", + "@funsaized/stet": "file:vendor/funsaized-stet-0.2.0.tgz", "@tanstack/react-router": "^1.131.0", "react": "^19.2.0", "react-dom": "^19.2.0" @@ -63,15 +63,16 @@ } }, "node_modules/@funsaized/stet": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@funsaized/stet/-/stet-0.1.0.tgz", - "integrity": "sha512-fX6SDXxMRfB8nOBd7AYiRMh5Ak6pWeofT5p1pW5ok4lRDZbeLWixKOzBgaaJH9uXohUtfktoUgU9Pz68HRyprw==", + "version": "0.2.0", + "resolved": "file:vendor/funsaized-stet-0.2.0.tgz", + "integrity": "sha512-ElD8ZDigUbpL+39jwkUmA017A44Z/hMdxfhCtKxLkyw/AE3T9RvkdNePFOGtyJ/Bow3KhBV+9H9Vu379AqVw0A==", "license": "MIT", "bin": { "stet": "agent/cli.mjs" }, "peerDependencies": { "@angular/core": ">=20 <22", + "@playwright/test": ">=1.63 <2", "react": ">=18 <20", "vue": ">=3 <4" }, @@ -79,6 +80,9 @@ "@angular/core": { "optional": true }, + "@playwright/test": { + "optional": true + }, "react": { "optional": true }, @@ -795,7 +799,7 @@ "version": "1.63.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.63.0" @@ -1722,7 +1726,7 @@ "version": "1.63.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.63.0" @@ -1738,7 +1742,7 @@ "version": "1.63.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/website/package.json b/website/package.json index 6d8dce0..b78665b 100644 --- a/website/package.json +++ b/website/package.json @@ -6,13 +6,18 @@ "scripts": { "dev": "vite --host 0.0.0.0", "build": "tsc --noEmit && vite build && vite build --ssr src/entry-server.tsx --outDir .ssr && node scripts/prerender.mjs && node scripts/test-seo.mjs", + "build:preview": "STET_CANDIDATE_PREVIEW=1 npm run build", "preview": "node scripts/serve.mjs", "test": "playwright test", + "test:snippets": "node --experimental-strip-types scripts/check-playground-snippets.mjs && node --experimental-strip-types scripts/check-framework-guides.mjs", + "capture:handoff": "node scripts/capture-handoff.mjs", + "capture:examples": "node scripts/capture-examples.mjs", "lint": "oxlint --deny-warnings .", "lint:fix": "oxlint --fix .", "format": "oxfmt .", "format:check": "oxfmt --check .", - "check": "npm run lint && npm run format:check && npm run build", + "check": "npm run lint && npm run format:check && npm run test:snippets && npm run build", + "check:preview": "STET_CANDIDATE_PREVIEW=1 npm run check", "test:e2e": "npm run build && playwright test", "social": "node scripts/render-social.mjs", "test:seo": "node scripts/test-seo.mjs" @@ -20,7 +25,7 @@ "dependencies": { "@fontsource/caveat": "^5.2.8", "@fontsource/dm-sans": "^5.2.8", - "@funsaized/stet": "0.1.0", + "@funsaized/stet": "file:vendor/funsaized-stet-0.2.0.tgz", "@tanstack/react-router": "^1.131.0", "react": "^19.2.0", "react-dom": "^19.2.0" diff --git a/website/public/images/example-feature-showcase.png b/website/public/images/example-feature-showcase.png new file mode 100644 index 0000000000000000000000000000000000000000..f60040b92003238ee42387884b78aa70cd301d59 GIT binary patch literal 40110 zcmdRWbySsK+a`*Fl7a}*D$)YdT_W8aLRz}JTO_2BknRrY&ZBg9cgLZ-IkWk_-#cr~ zTC--&_s#qc!efPJ;9mqd z<@#>~gck_np9K}2lXmBk1zs8vA|LvcUPOn!$@q>YXmik1G+SS!WTtMhQ?0aMxxyCvmy2L_Alq> z!Hjnb&5ZL+jOA64!1-wuv}r`LGRl@R$})~feq5$70-W>Gh@U8g2oZfDib8s*hkGlLGeef9^Z4gHW`nQh={d!(@a{y^ zU%a9o<(GIl{H09*+~ROLv+}2Rx~ioN8#*oC+Gb&1fxOdeMDWd@NB%i6Jg;7%ct@iK zqp-3#;Ddt*14CmYbTl-I^z<}}lS8@>hd6w`r)Fe4{?+o`oz+(m9QZ?SN-zVaPZN#R za)9^<9LbeCY(2oy`{(TEIxoPdHBQSL@UcYouPfxA`GCK^WxhaqI9>ED+QT~w`}Fz= z_#pE8_zZk-p#1B?XcWja$tC|>nU#vYhPk0362zsl%HczQAC!QzT6HxC4V)|BN9~7n z&Bfuu_$xgx&$#nm6FH1n7sq7nCzc+RDEoH^4=y+>T6>BX+Ji=sg2V^0NBewE%6qt` zitsRZImTc3NW?szzVeMLD=V$JVT|A)!YfF;j@;Dj#~cYocfr8G=YD>E<-PAIDUA#a z9=<<}FX&Uxih@MO)4YX$Z@zbR4`gIE>|^;jM9QR}S6H~y5lWcdAD5CcQKt8U&*S=o zR^w-P7;L-{gM`QVd{>p0*6iVBndS`Mis*TH!Lzc~Izkhlk=EC9adFj$60$qm*y!;2 z{lR14;UT!vIMx9tFGUsHGW4miJHW=`d0cD*BfM^pL<1S4?*l_a?dOA^o3>|Y<`SStAb{Hz};9+*t@GW>glW2-+Y zvp7yprP-wAN}IT#pkR&VyrR5(x{FbNf^?K_MkXG;+S=yD&mzZ>HgTaJ=r4qB<_Y-~-JHaDG? z{yZAa=XTnE&9>WXAQ?-_Q43%C>tkuTKi}Y#%4jEIY`j$y1ufESIQRBJRw-69(eCs- zO#4efKrmZlk;t-5h|&(5EMX{mgoJdBgLt+-@756#FN#6BQ0elrf3{Zq<$sJ4PUbrn zwSE8(@M!DojE%cK-%DItT9QfTuB)xx+JecA_=%)&d&yLy2{Y^H=pZ7U<_=Vy?{W7f zaTW>($7DQBN>0v|PMoMv88qmP8ScenNP(Pht@p-ipB})^e4lGx1}3e4HyxavnVBf7 z$X(_-cc8+|%FP7>gTY{?rlxP-zEvsFBIeQWilTfJ;Qg?4DoRE^8p)bB7bHt_f9hhy z=>nfRlFl{VEYas2vslip)hv2Ov9Z##g{8=*a!F$@iv=a!Ln!EDQp{%W`T6zLXU`X! z8d_r6*w}Uv988AoL<~jD(Y$bb_Dfg$`{`I%Sf(Rv+0vBhD%yi%&FAY4x+B|rW9YIu zOs*@p4GlLQHl<=@0ftWs)Lkb0fKmp3$nB3GE#Aa$Z91;AA6B0F1u^k;_`Kop>zxDW zO$w5Ng2Jay->Hqr)coIJyJEPVz)5LQP|<&B7S%oF*LseLX{6oJ(XqCsul@z)dmMaW zk*w&^(UFaah(G+c!l(QbWmxLv-o;`bK6B3CSMcz(NW~I2pA6l1RaN$JH2PNBxmr!G z$e2W>EH$N-l;>9hn2R#BDE?KX2Z&` zF!~3d@~gP+K8 z2KRnfVR!XCstRhkaks_E#cQdgSqphXKVNXWxxIY((!>D!<;#qW4C;3g@ZcR2ll-zW zn+zzSVh+-05s|jGw!Hj;y4w0m(Bi)hqBWRq-6BBCPZr_s_4${g@iy?JfASM4?4cPw%DcM}zd-Tv<#P2?_IW zRaNM`uR&aprD0_i20!ZRqV^%>k>$lHBsb-?wH!?E=7xskL{(&U(W$$|LBJ*N$jdJ@ zGciH!%*xKe!F?r01|^z>Ywr`EvS z1O?w}#$Rl+f9oLTb^pjpURPQwC#WQc^&r9{U!gJor?^fB0)5J!LxWHQ0s>B1KLWg>;HU*>E@ns(Q0YA#rOy-N^TWg@co`?lYo> z#(ccQF?H*kVK<+U&=8gT`XFFA-(c_g*g6&#yonvH^CUf#RO90@Fi7E#4GY;Uw(Cms ze%f-X&};Yf_jeBrP|LUe@Wre$o2_U)cf@%6R`B`rBg}(Ex1(EpZYFN-#N|bomch3s zrY89K@ukVy{{9`Rs_`r6d2Ju_>TiSWbrn|X`@KGbHzSuk#>DJ(bm_V)5Z zSF6N^X6Xw0R~lK?dSR}QR6if)BfDqvC+b%(?(nyWvSF1~=<{cXi%oXwo}MXz`T4C>^92Nm1kMx`hW^t|*J-FGAk= z`gKtAhcjdYw9t_D&VcY}=%Zg51qbNmW#yV5%>;KgicD(rjYbVlVj+eB%aI^`LERj9 zSkS|KB?mUDcQo!tuy(0m0pa2EWfrJpm#D8Ms;e~{&RC*xB$1Gt>Yb0~#z+gdN@D!| z{GT90_lJYA*lZV5k{4x@W$yAr-UvBwoCY$kQhZG9nk^mjaB~{at_=Z;dO4>QYZXNO z$2PcLj#PVN-5=`J>CWZo0CPV( zV}A$_$nFC=KYvD)b>kr-K`~t0o24Wrc^y`^V)}Qqk(+SnDERo^a~RJqdRfdJcDCMJ zOfj;TXy2WLvo7R@85tUiB}z7ios$ntMc~8x0=#?e@#+ zZwmA8l ziiRd50{LRE&TccOp&{2f+2!ggmOjP6h~M!vEhWWMMC5u;eX@5FjQU%`Iy0uvyOzZg zjq0sj>O~%}BlEVcGM0@%I2oc)CU6*SzMoZUL}ESY%gNh2X{o7YuWbAS1F4gD&EgXw zhGHtRu@70`Rb3r>#vkDojTM8>P0sfrk5P{t6XGUJ=X2(dGJp8$wV7G2ZC|}RRJF0O zF*ldmsovA#@shv_Pi**M%s#DL%Bt-b|_C%^rhDcTUA~6;#ZZ!<&BfoVq?C_qIe~C0Y4jC0uq(a8EhRDxzjZRLOwB_V}T4~I`KL!RSj0o z#-*D=mYga|*j_4_p;B97r)T((cSopDkDqrlMLt za$c0VqGn4VWe*6isV-}BSfx43mmg>y842p)V7FJTaU75845y-jEqax<)j9VxVjK8= z(n6Le@smCm)7LSXPlwh&0axarIbNb z-gZk!Bgwj;jVoV#Y)@&jEXCj7-&kqg42)O+T_N5}s+E7~=(nHUw7&n!kjq64c_EJm zA>*r`QBf!N^MqU%C(blX9l%W5+uFt)>=hNR4l((_qY9v7pZAn7 zwZ18L807yEMti>LB7#ZCdfqFK7J%h1_~6r`bB^ITQ*Fbm4b=zsr+tB)k7QT)+8BRz z_Ko#bM074SBkn#~bVIi3prGhasQUUaC%#`v=nutj9u^g0+pkpFhYFbL(f?u1{C`kv z-vx)F>47mIaQKrV#jA5agc1Y|>~osu$q&{4pZ`SV{$C!&|2r3Uum8EfLqtM5JT(3s zPRL%&{3g}yVn2+SJI6*(cE%D~a+jR1I7JQIl^_bHPf&31U@A9!TxHeue0O<$eZ1MD zVYwBWblo~eE{V(Ub{>-d7G!5OD{aq5)vDI0lYad=+pAd|G41#)$~-rbA>6Ck;OMk7 z5i0+UX)hY3h3_fC<%j%_LD8wH{4U4rvZ;J~L+L^d)qAG1;{$ztD43W~hnYe)2(5&G54ul1cX%bc7H+W-F+ey3SSWpzB%2a$cbF zSJZ=>FOtCDdhXAq#+PT}h@IflDMUs!*zfmQ_l7KS)vjEsM$rhM~@&-Z6L z-NR}dWgj8%>7k(cydxvCwX-t;6_b*-HneT5q|>YD_wP1E$<4uJNg0_)HYM@xQI=Ww zaAs&CTY;G|)xNuiU}a~CW`n7Ar`C!hxrq1d9t12-!Tk*m!qQ33pO0OC{`@4Ks@*Pm z)vH273*U4!M@2R$_Y?{3_2Ky*A%XGA zI_pyK{baE=h#nufXw=lzJNxG??i*dMuaTOa_UCFHPNqk5v9PcP1_tal`ifLaQnnPR zrtP+t{@!4c@VK2T3JbT-%(#N(PUdz>7aty$2SuLC=@u3`DOTDe7y%)Zet@+jI(mOz z9w^Rb`P?r*a&XiZXRgZf-;dSW=sCjn0sOE#pBNo2)*6h*usxcKi9T2B>2V#h4GJKZ zM$>gztRO=wzZ)(VetbgYNSdGzKC@wceSJ{%Pi&J0$Nk1NJ8CvIdR|^>Y3crn2>{oO zwYBLFrZk1TXB-|z#l64o8Vtv3djg;wZTI0+w%E3oSb9w^=PeL{22BK|6WP=&469O7 z_!v-^yEYr);rrvYm)HJfm+wIg3(-A0DW!4VDt43JKa%~u?#mn zZugDSI}`Hq@{`4ywDk0zFV8z#GqGOn&lJ%X{kBer5x^?x7mOOMZWf+9W6Smao``J~ zqxQ&r`t%CmKn>2@E3-9LXXt3Alf@Qhddvnriin7a!@A{8DB_Zm^QAgMii#|+f&&B7 zcTtH6$wWd9PfztxLkL)zsJ2+?!oq6C3zapST=xc&X9rWEm+P^q+DOf}?}23?;7C)m z3{C#+4wer&T7|maUNWK&sk*fIKDU^yj*^d)S+?647#|Sz1W+d z%`_SmM#al7C@61t+@CA+ymJ*9k`<(6783F)EiLWqlOkB%*ogTQPsz*6)#-kD_?Cw! zNdzE9DO?Ugy!}yAQ*qr<(9poZoWndwZkWA{TIp9$Ik};5{#y>i-7j4H!-pxnZu_8m zPZ#>Z09{MX+8$FcjKL@4C;??<3AiLD$Izl-1|WbnR`VHl1@-mUAl3VP!-|AKATa#x zFj_osquJvoCo4;C)_8AsZ?CGdis9x`ZcUWdcDd!L4k~qI9EZ(gKix$y{%oUg5**&k z%gfvQ8#OK=$UvG)FfLQ|^6`3a7zwWgmvNPmuEO)(c>aHJKN9teNk5k z{^(+}M~(e>$+1Q7Y*Jh93@A8Qj66Ppnd($doQd~b9; z3!=C?>LlHpb^2M&g`+Q($QJr8ak|2=#$pnzGT0IKcNdGfvdPs&nu&B|n=(h^M#@ip34Wj3lZn~eEhYPUJid;b`VPE1KU6iK;AjXWvY73t;z z{@w86sE)^Y=#ArAMC*@j_vtdd+@sab3Oy{G6QfxFlejTn!f-RTNSKf%FN{#*`$ zIIB#%!?0v-V{Z48e)g8qZ@8}T`zAUsE35%YbhoQxatm}d7I+njP$CsOr4ji$mvTwE zn3%nwq7ie6;fZIw=%!nc1eM@cS5!X)W?7KuEjO-3Y3sYght?w|T7$e>eB zVyQ2lnMD9Ayz~q3X4Kc+y}fS7@4r!#!RSn9e&eGzdcnh}xq~Xzs!hca4?Y`0ZBCGQ zxmk_hsEy<|zvSijy#4IxhofB{Sa`BI@LW`(R*3P1*V@%dOT=WcR<>3zs83265Fe+= z{Sm~E9)RCb;AFe!v7gLT6?!vhMSoF_E!Lz$%6NqGE|JBgFIxU2=XUR0X2UV~S@_pW z{sy@>T#=D}RegQ>edIQ|auY!q!o#{96dk8T#E@gBZ+WpnE7)9l#?G)YQmVT4XyNPw z2uMMQ{NU~x6%{8|&^yPynJOzvFLkJ(hvOPR{{7MLiSY5?e&|S{OloiD+t}Y9cxJhK zc;WRykGcg-&QmL8%Na(lbD5t*biUsD!!w#I;}M(k3nr{2EV3ub#s(-9dDYI(xU;<-h$kr_K~vSj=W(UU#uw}1tCwdLaGm@5FP$J*2P=((>B8K@0o|UA z;fmxYV!~3CVgKw17VGsHB@@Xkw5YbzaPt`D+rv-ijuq8q8w}Sy>xefOke2OLF|$PM zP#nrZ866c~etvBce3(|&WgvB?#Nt1DSn0Hrg@v_>LMjPFvE`FZ202`BtU2_%y9)>k zD(duOaq(Mwzhe#UIRZzHO zp!?7-ncS4RM49m2$7fnSGq2_dxX414BHbq*QoCZXoJNlunVMoa+{WnqUK`N^@d#!5 z1uQD5Xnt;Pilm+uT9lqN$`8MNd3((Yp&yeJu7D8O{`vMOx*;Q3hFC#LDSrv1atKfJ zVNhyA9i1}Qaf0&+Jy^vv6cpXgT+~l5h&AW2*QquC8oB~VI39(TX2YxGN4;&e1e5d7 z*BkOOUI)4n0oRYFvl1>;tz6HKj><~(WRtab@4n#CAKtWE z&NmvEoA0iyyi1}b^YIF=3kxfD_A@tEqn?El;nJy%H8@#WSnPp#2ECv8Xg@W?040h6 zs8)_dUZ>sZmOM*CdHG0Wd$5PcfdVp>8&rNg?_U$RV)0Tto3(=KA+N0PEmXBGTqeaw z$Y;EF@XN}{83E`TBSfd;dmGZ3vK(dDW$VO5Og<8oNY+9t*!*%vFSs|BTahPA7Sp!& z3QIbAAwuE%x7?}pTD4+>n+!<@!^vJ=uiRqCrz>vV{EIcAGYzsUt+1^~rwGn15F zwer#r<~6-(RkVb2(gMA^Ht?S=<&%{nCJX-t{N4`}>RZ#xhuKtz;Az z4=5G|{j)osJoOg@L(uRb#R~dwRuJDUqAQZ8NjJ-kcL- zkn+t{;PJ;b*xZSzZWscmu<1{auB50KFPpydPa-2&rXA-+jH4*`lfiQzq(%K*U1@fS zhdJBl7PIqErvrnpUjrj$5mT7HGaQV;4{FSNay{>^$J#}<$+fk$VWv6-+26l^PxZR0 z1G%2rKp#jGPp<238d1FNbW0>@Mu&iAO> zkI(mJL6{sS{Ar>NW*3Gu<77Oj$a!88Uu`@b=^>6haMU8i=El%g4US2xBEMJ!df*ewKT(Dog zqE;KZnNxKoVRt0DdAfn30Dc=}OpES5~GlxgG9JA)Kk*xaBA zQAE+{@4@>AhJA133{KW?BHE1MO2-u`%o#Y4+_1K>aUI~E7=q>A`#Uc$PpFf_1z-5z zSU?znt$$TZ%~;-|X}fFi+|2g8iv9W1`SK8Jn9=G9^sS|lQD^ki8(K6F&2XYC^nh;} zRVdsKbLPCDZcjr*#@L-KImi%31w^?VPrISd>%Xa{Ue~A84u3hgxz$xwk&u7v@|O;7 zMusOR--X~a`?dq%NacezquW5zLGz90(MtOnY&i5NZ@e4EDT~UV^dQDBcrq3aY^?WlhGV` zw0hlj32C~1!~TTzjWvbE>6t1hyVbDn-`l4lLr@+CwI3e8f@kS%!X(U;=Owt z{e*y@BH*z8Sy#s?+zr1y!EA-r?9MVNjg&!tjGHL~?&a)&{~OyUEzboov$NC73m?kd z_mEO8YQbtW+kHq6Vwo51#zuc4fK8}KtYA*7p;AS!pMGRwvNhSt$Ssk5)c;Tl5lune zE-Yc8H1sqC?3Qyae$awK_~xYchoavPENfDdlppnd?dncpW+t=8%&E5z(sMY;G^qK` zcc+t6Q)SzWkG#4?@^xDMckVT6blZ>71YU%R(?28#_B$ zaq$#BJ=aUO`D@K|B_2VUm9I*CKeY{ zT}NSKHl<}{@w{$;rmrz3Ho@b0?mRM8!ANHexL%y0we@v*r5dY6TYGz!sMFwp=!$eC zi1p#cVxrUD%+;DW!WnLnMqRm$-hBxlsCoqkl0WgL<4pPa`}_WY=JsoSk^5-1;EvLE zRU`AGiPL`TD{$S)#cEYva6Ya|Y;0_IclURh@6_*S@kMREjPF8Rd)qqNDJb51p7iT; zl|F2_tTkWb_IqhYDrA)5<}1UIweF~C=y^{@z(ajQt5Te1Y_&aN(DXjrE^X!kO3CwY z(Yo?TVCeHL^fQf}YQk!Cs@XYhF=sS z9ipS6>;Oj7;N;QT+Pb>30x%Q&dxkd+_6IW-AbAFyKiU|}1G+OVH$}_;FHMc`We(s@ z5v=iA-hhw)g3{*yGVG@K+ON)j*<4u}*9)H~MU(i)HG0zc*x2hZ)doZ113v9Bv!yn7 zE#Op*$2H~&91f;`+KhNP2} z`_>xS)JtZfm)OT#yu6`TyHr3-v$F-;A!PamoB1VaKUHZln_iqr26>5thnLLb2~<0Y zj}X2Qh>t^ojuV)f~%y_wlgPQN-6 z>t|QBnQ3N9ZhKfpdU`j&Yo%Kgujie~AhaTn=(X$RO0*EUZ@6|PUnz~##?n`s%xXzW z&fbjKFf*S3ZfYQv+rmPxu&7Ar>4LAHR-NteW=mb0FD76C6ho3DP$mK8d4C#s5&trr zgs*F0psc()$NUM|RzjWY)rvvt$$dBp?%L+4^Fw&Uz1qPeFZv{JP;i@cK|R=^6{9@C z)FY;D3{lwwR~6sVV!d0E#fmRAs6Kqic&y?< zkNfHsK0aZu@kqtDPOC-V;dieEe@w8v1pF#CjbMfdFV)WP9QfA=XaIkAsc6OP46LyeWY)nXptA#^Q3 zWLU_R{i3RxZ)RM&esH}YBO}4WT=TQ(%?uOBGIDyTDRHoGXNJ1uL`7?exD#;Gt-e~{ z@8YKhZ|dn~SeQ*J%gfh!oYY@zM~MeMkC~V#C`j6oMncGY-2Hr5$tl>rYv&ECg9D-v z83CWy!p)L5FY@_b4zsU67kticb0QS*y*b%A)u!|GEG+u>gB7}ZnbY|Bhu8cQNoRcP zySoxbIb=W1i?qsolkJZ$har?<3Gu4V&MbAd>mwVXJAM^i+E6N!S3xw|>krGkb?sor z*VYNa#PWLzEm9N7omD8L8J01bmA+R>D>Z>HB*(|&?}p$I5zRqGMSpl{0e>d(#kh2r zLVnlb^zc!2U+Z-%x^DS5z^YgT@>RQ&8*u|enKqs?$>fGbQg zqAVDIQQ_g{_@08|Stk*C=6ISBeb{)?^GQSybiDfmJ*^Ph;6@CkK?JVZpd`Y)z#Gr z9=#G?k}p+Vx%ttpk!)&heH}#qqi2i$AceF^W2nWRi=7b=LQNDHXiZ$J+DkKJ0;?_3 z5WzP=&HnI7QGX5Z4teyFLOOAFma}y86X&FGhoha{#ZRwSNrd$Z{;T(zi!z$^!|x^J zU;fI*!xch*KsCE~YJ| zrMxojBFzc~5ep_UbveXM|C##w8cU6xg^F-*Z=EYg7B_oNPR{VuOFHy18|ygc6H!Up zLTdhjv{-uj;!)4beUooc70BF23T1W+vW1~`C62&IqLvd0 zMg1q)_*?q;cDK*C{W~EpfQ>G--eKDtgEze>u`tq}EaP+If}3=h_fVB;i)S*wkj_4R z?xsZzx%)s)Zh#N0(F|B)*B6jM?_C_spTU&UGWC4IH0$L>18;ZUxixe{QKE}XW}8== z+?=L9ZVe=>&%2${m*L=7K#;PF^_uVoA5)>Z;vTMbZdV8ieMcnWcf2^vk{UUT(0jcN z!1+A&QuT1HzRsr1u_CuYu!rO=L-yRTHT|&b9d}%;;rre`{t7C)st2R{B%vqA$Jyf@ z(IOq+*A{Wg!|8MVNf!xns`~8h&ytcgr(#*8^dhleKE!GSz(LsOU@g4U{9b(yw=&X! zstiJ|y!3W=IBIKapKF)rHo*rAGoya}62HTujiJwqMHiMRH;OHv7>v}^sUu`)2nq2E z!)IRx+@ZL5ph}5GR!TmQ9IzdmzuT-YeQ#M)Ui_&6Be_vFH6T0~6iKqu(&^D!f!SRt z^ZiX&$v1=?tXW;H)z!%jiJ}7Epa}^D0P$|p&+5fhq;13x82WGuD%roqv0F6JLm5hm znW7jAbsnP?HZNhQiL6e|Av1gn4P@n+lDqc!s5I&B+zENXw=up1@+x>P`D$OjbOE;g zF%=tHVeGg=4BWIU=5s4m;OB z;otNqa6hY9D=Gc`tCxz*A|2?h7>DJFxg2zQ;b)sOg(~h;Xpwfnor4*EPfy1>yPs>? zNlb{8;xz-Pvq3^r#1G0dlq-H$F=GAGk@?ovo4)4-ZAB5tWG~g}EQmI}n8|f?(sABC zv+9ZQzSiP)fZ0w1@J{#fQ+2>U%T0)?S(sBrMkakGR@%*~t^LAmXD5O|I(fWkoI}J- zfPlc4ZhAyJVsEopAl*PbTO-%wt)<}9J<@*Cx(LO?t@RqbNI4)DIkd>s@jRk<^T!S^;^Dy&6T=+Ubo)j&@Z!7TJ`qL0^bz0$I9Bfe#}!_wS1DC z$64pD_g&G|W3$r0BPHzxA$>g`clr&J?${K%*FB%p1k1TWswd)9qLiu+5_*HnYR`EQ zA-*T3-vH*QVL2~>kH34J5g0|{BoIu56Y=iv#z(Cere>E|z47t`ZcYujIac->B6iP_ zMWE>U_q z$m-l!EJrHiZzZpE|18lZvdi68ejPrZaXdGeAGVc{7C*W;*%_(Iy-YR*o6%At-Mbk> zMoa4K1p#(aYOLrT&aP~ADTOT2&6#gE7#}$~MO%Bv%*=G)y(6md^9Zcv2{tw!t?jqT zZP$`ikblooT2eYNC`56Cnwx{8Ag>_C#HPGPJwAal7iEARoxCg~Gs7IHHbzN+fpJu~b(JQ*;2o}sD8})wsWvfdZ9l4d_gqQq z93~ip=)a#fKFXdZ8ef1-Y15!oDkI!SA6j4U??H^56LNb;qOY%Cxih4CKlA&yMVy4f z1ToNKq&}e+Up}$l-`&PYZm>Qqxq6K7?Y2NAH|}$Zj}{;*cUCotHU5nSz_uCXa5WYz z$1ZS)P7A7=n%el80WJSWu~mjK5XNq8n6_KnIG=aWou7^h6hUtwRhR#Wv;@Y5{%4}~ z|I4ZL|AtKdKk-DR(3Uiyq)3e;?Ej0%vJcGG&0DkOp`T z*vjhF=4EANoQ}JB)(UEBu*A}j_$Lw{Krs#H+HwFob?0TEZ9hIfUd#VX^s9StFxE)$ zJsH_55I!q@^N0uO6ftpo*Q6)7a~>bg6t(jAloTyPWeW60w+qMNjP^&5WR0grMr;8z z2iT(*bks$Ee2_6cueT{-e3{Dt&7f1O?CR@F@>lu&#dEC->%J#151=p`HwU?0716(c z{{|x+NM-ked*4|tG!BoCC$pNd&5~2v2%29N&(~jT9H$?Nh;+Q9loAt@m}@e_^OFO2 z)TlDeE-p^t2?ope4VD&U8PV*yM_?f5YJVW!3grJoLt`9{yO&pXn_&8!fI`M3!yQ;ExgrpT z)S6s>{SYN{?Mvjhn&?vn1v~~iIyx%q&PcwUf`YETqW2H>*Y{>EE|1uCLl-%C%IZ|ax2k>ddj9eiHnPiuE$A#qv|UkcI{jJ?pJ;O`a+$D#|8t*c`10yWMaB zr8gsESzX;Nw|5?CP<=K4wYZE1%xNY71_4m4U-dX|$@KIb1tcXCm%Vi8)WL!wA^Goq z%ZZ`j1?>MlcZ0H{V;B$ZLu<+SXQeqp7R%k*Own*al(>OPdjl7j#c-of6`ha`hpu%Y zKq%{#^U=yvKZ@QM`h)4<#R?H(g)wGfM4&5#j4FWF`YSOJ06f#LXk!}cYq>bNC4YY= z8Nq!|X|jj>U>G?x`{)_;xd4#b2kaX!Z;|DEJq8Ab*Im_HMn-Sf;Cn!$R$I&t0SvzK zF(~I*SS4T3$!oMM4*JZ5`yP5s2a*Sb0uj)1 zBa8b_myi1=9HeE2z6U+!zZ-rYnpvRECvPch-at$9zL7j_zujzUxqbY%*Ne2hJ)|5+ zzaz23Q>HsRb&hS`!{hnCUpZasiN7M@rOwYkR#b8}E$A`5H6l>XiG>yLSJQP*aS(q|eJst+d)8HhGo7;hh4!{I-IPmWR7O%VCdTGp7yG24yKEJ7au znmiJ4Pg{qNXIjj0@;p6Na*EN~a&e-V_X3RQI5;@#8+dCQqjB#Dx@A^mv!4}8$T{bx z=Vas$cny7vLEx63KM{_LR>jr^b-L!e3XmV}_t{Btxq>2+>)(Vu|_o%7OT7?z|&fF6#J z`~}kb`g#V#!$WDrG&PwS7=Fm3x$BUCzyIA1_MeDz|6?E7*d{)bfuSM5+_tYyHl+*# zf`YD32KfOpdk0vljZtbEngmueWsRha`{IY5!b$fOou5#sm$P$iNeKf4A2bc+W@c76 zAL#;;{vmeq;epnIj9Y0yL5Dm(IywT_RfszYzvmoqx4s@ zcie+5FTX8n;qZ>3Q%6EZzPY+`*d7sX>;tI!LrX%Ta#2>+`+_2XLb4nWXNpqFrm!$E zWpr?GaG(*gh2YkB+&C8e$WgE~xjbABC18!5`WmDNA}up$_CUp>2UzAn5+|hg13^?= z9Ea_i7y%$50dj)!D=S;>0~3g+)R3GnYHA9;f({Cd3GDp*P|(j}X=zCgOXBmWFC74~ zpT#;mBj9m2dnk)_h9<&T!2^|;@ix>IP9gt_bb5+#@iXec8-ncaV0!WB3s=O5hfsk#!$_!7~1c&q2zpNooc8*%)jsYJZ z|N8p+?PH*Nu=ZU+9A;Ad)86ifWK5Gh2CURTQ7qRHhGjNa%ZLuy@V9*p_z7Bh5kMOW z39W)g#LCZhb|;|ajGC4fBBI|O^a`h3&czggT&2zEL)Bj#TY22v&jDY}%z6j9&S@PE z7LvceB@n6ra=KI`Dyein1~xKbKn%ou`-TwcFft?WF7y|LPHm>Xl>Fv=Pf}?ZL0Al| zn>%2{+Sx~xqh*fQQ3dF_5@dd~T(9Z%Z1f!SIZ=bB2`|54AX=I=! zS1@SSk5!sLP{)7x_ zql+Div@wrElf2ZQjjvRmx(z>j#=gQbuv7gqP*9xmjHCfDhm@CV0>8fln>9T>9VBEK zkH?UtA1!|vLxOcje%WmOfw{N0H~m_yqyB}EkkGOQ1qH>}F5o}ag4)KjT&gN7sWPw! zKE=Ov&FTLF!vG~hUOJ!6>(9A%LD;|&-xo#sVPlWkogS}CN<7Zb%R?jOOWG5T^mnz` zx~1z27%Ni0n65CoySczCO8tb7@(cvkwr8$sz{D7WWbzQsl<2;ce#>|yVl9M8&c9{_ znxph9gWhq%W;4F|Tvz8=fE;410Jsa+GZ^D{0ZMg&*U#lg0;F*Q30Y0YfTRq_js%~R znCvJtI9j-hd*dVq=+b8Kyb$JGUS7sli8|)RF;Hl0U7-9-70trTOzVIQV#Sz7YAvL% zrzc3}k3Ey&lZZCZdz#nlU$04(X?L)|8|0lw`{VI5*jmPWIbcA3f4xh;83TB?4ixT9 zU>x@hL9eKG(_TW28BvJAHX;Ikt~Dno$38&?D-8)yvy9&p{Y>f%Oh1ynWKF~s^$-Uw zTBngI`A8BlxmaAXt;!5TIej7TXXE1e#3;sjqO!V5I5>dkVXqV!W2LaroQfP{J9jf0 zTNT-6fvM`{N@z%<&{Ox@fy%>CGf&bISes(A`wl7yL1gaN>RFv3_;$c+fo)YTh>*NW zeIy``YGq~BlPrfI*p1!uMUEdMd4Sqr1b7_a98pl8HT-z}*Ms?Vq@zQG!(aDJ5G9o` zKpg0aOe`#()IK#yMwdoF|CR&m^-t&U=_$P; zF+B$!X{O3dC1U{WEBQyJC%{|h6H4kP<6OyGP5M56nRho;gLLlc;pApAIQ%1jww~;HGDx)NCu5WJsVMc7aP#it&UQ|M^Y|-PTLfbX4@vha5 z?AE;ojIT865BXdwy6*P#fN&$PR)1HWDYPeUQ6N4_J(2hj+*Bw+GD4t%#r0TfIW5U7as%2Ge<#&jgaQfHwgWR34W8K`4|87zf`7Y z=Y15DmgZ$(2Q(1av)|_C%vo8{MHq2jA>k*)85!(;9}oOD7LdaXPC|O#u7B9Na!G$&XX^AhZf0?^iDCdrxzb- zJ=;&=v=zbKl!sLaWPGLx1zR{#lt{CkbmIrWY)xNOAxVeV$bcEJYgi`&At22vsLzPa;jA*e`g)k;y+@tQ-AG=!mbcg4JLH}TX0?WZvPUbB$?t1R9 z-Jgn|VHSkpr1QD57s^ll{P+hn@dy*YWMrj#??;RK%vReP^-ytbvvqS3>WV+RF~;nE&)3TP$m}KDGI>ai zcAyVlU83~LiH_Vn^HkHlTq@qE3NaW{mXkZAx%6-qiP`Fkt8Lq4XqAZ0nv?ph&qMZd z92e)9kCIZwJ+f5%c(Nm)N_(J=Mn%naBhhJiWi>b`$YgX_F{fNyJ%fe3nojaQnd<2E zS8|GMe_vsg{kF|uS+N{@yrRp_ZV4Xp6eO_uS*4Zc-qE!-;-Bo9&5jB<>t7yM_4cw@ z$!jHCiWwMC&~n#+?l0*JtBpaTn(fq=^{#5jH4OWl#@8*J+D#}f@WbKWQ0U-SRPFk6 z>)x=Jl;=e}2X6oG*zp}erc)T>|h^)T((usweFz@Vxv9zj{zn~^{Vt3_dk z8xOo>tZj3#M{|wwdh-Na0rbh5`yu&c)q8wJxrHx@UoPfkcMgt zh>wbzG@9yLM{Pbjwx_8I@ry6J#3p;U>K~Hqv^qyI!yWRbpV@kBFjl4*^tV!#S@@mx zLSK@TAntNGZV#OvjZP|xXlom7GrV*n=D@q9ct=4oJkeo(GNU@9M$l7m{a`QxO3CQ9 zW?K|Q_qI!i6aDI=0*1;PgTb2{8|yvV;cYjWsZGzCZVrWW&$V`YNAWCWQ;*jdpm@(v zPxpo?mRvq+_Ea!1Xw}K;ZGC94olmJ76*-u#KirL2#C{2JB3`4+ZchDZJic-uC55)H zIW3YaOTg@Ow1{})$WiAto{w1A5xRTU8wsTzcwseLe78AKbbHiQOx^(3dVzE6*cy}B zBAy`~`6aOpH%OV@rZ`4g^Dd=P7j@3#axUNduBbuQgk#AiAidf0sotBvpV--ttdIFB zH~yRp_u@)jJG49M(5&YiM0u!t@m3p6hfue|r|hX14&a3P0*mJ*LS$M$d@C3RHEQ*g zd*xV=BIQLou(Poi=R@YWv`i&6r5!ukAZil*y*{ff#WvJ=l7PiHv6;Gx%cZd}Lb&Y1KApR0>|t+FD#1YwxyrXB;JlOv7TZ+%ZRO#LOVV|r5Z@9a0ra#mm!(?Q^tnQ zF{R#rWpbM2m$-e~BZJK*E@H7l_n``XjIv9Hm6g?*6syMkKI=}@!yjX7?Sdmum|5k> zLAB9TR3~EWAX%Ddbh4*B=FnhjHm{Ujp3v(iE-gdJ&aT+)EN8JReSX^@b-yS&I@&l* zvNH#2!SUkNODpZ!NIH|WlFzzzQ7^tPk+eWuF^*{K4|0*V}qoDp{o>Ij`Q^Q$~ zx3C?a#q`xho=)bkUsrW`#5Ybnp0L4aEH^I}v))uY3456`DQWWCXvsl(IyOsg8NrS^ zNhcu1`O@pFZTPp`!k)genk9P~GmzS~9=rKA>1SMIV(i5riPiG!9&{YZYZDRF6Zm4A zo`Ft6R8(S0gfKBXuseBRcReY=k0X@(HAwkfOtMO5%kxo%g`pp&KlNtg3{Nq*1=S1{ zmN7#pVZ_UGk=|3PEejT$SF0z`(#gQ!84*ml9wcp(vuJOCU;^Hn^&Ax;Y*OP3>50FqN1& zt!cPv7l3VjKb448t5FXb$)8oAsx6K6pbDL*qPpiZqgQjh2ELFqY(h*GI|5g~nG`+5 zv5oJWQZ5T>TxI5Jb&3ZC$T`{?^gfel@cTzh+^ncUJvi%I8Gj$=oG z&kMP`KIS5qUzC;RJ5<0TP-sX)!L`1?&p%Xb#1}s;aMLOx#_;XUi zO^4(3U%v{=&9t=ME~61#pZ0&WiYKy}g!sMiAYY&BN8XpXlGUYM2paS1^1t#Ny!;Wz zz^mZ2GuPrw8~|?g-+!yCN-RE86Dj{=E1I>3*6YjFV8@BVgT4yRA=sEG&9m8`ytwM1 zSs$8o*%~VQJ!;2Wb{OGsVtAjIjL(z%4Q-)*QTgYX@N!XXYzQ{Ck9Pgtt&Lr7kMM9G zK3=6(7DZYQHPSn2E4dqXfmdZt_Z8wLxYI#r-Bo6_pH0PC?4f3hSe2~3z-1s!Ck-R2 zH#R8<9X%)>x**)YFUF33Sz=f5_EDHF(LfQ20!>jRb5UWkQg5ML8>FVWN@zPOD)=r$ zOTX?sj$!;)eO65nBsG1|rl9NDZd`@Yb){*;E;0W;O>zn-m$>p1pwO)nBC}5t$G^ToS)HFdHfUbmM~& zG76Kyaeeo)fj}U#I*@u8VX{MDjh=jO0{upp{95Ku{qx{nvo?2*k_^8p_Fu-tZ;L(m zS+QazA+~g@UL0O6g+UrSIJmf&WJCKiO>pmrxB=TcH<9L4(!6NWTO3-v*R#3Ug?9D= z{AYQ|sew#E;7Kd`&wTShZ#-6^%~MwdrNF&{jIWx>CdL{LAy|Lnu;74Ac0isk zt*p~k>p@Zo%UxJA(6pNW-i+95$CN*|Vdszr1`iyZvTZi=o0#>Jac-)EsO65JsAX zmuJm1>Z;T;L2Fw0gq5`~{gI0JW$3iU?1Y&3Q;Lk>X3BvcTIq$dm2E|#}O=k0rr{GPHCW`NSL`k3l1qR(= z8#N6bE*>3CUarM>?nac3uhDgYdQ}_)O}4aD02RR;OSGI+ygpll=QY37)Hyhg%MyT z3~(^OMn;0Mw6v`EZk7MaALx_!V?QW^j-9rIhW_kNn%IQ}C?EuYu`}DS)I*Kjd|_cd zAUf_~RFUIUJ)Y7($Xd+e15HGO`=#IKr?3AMVT3$&a&)vT9oOzPM@CHi`5|0^n(bz7-e+s;Wo{(n;+Pb8-WJFcV&E0x@aipW_1+_G zfMAOU89bPNU@lJrcMQm}WN;%S!*>88pDVN;)tSl5%-$9URUaS*=Pmp0Fazf!cGuL> zl+pf)z-WPr>h|TuLal`;0c8ltHfc~-HF+8k560x1=lFSWcu`)Sgo=ZM1CUkyDk)JO z?K42m9@ix&MOm2HzP8yc?yPR0*l~1zae;=m+MBXpu)RUkep z&cM>9AUF4IeNA4kfa$2PqqTm`hQoaA7Za23Rq<7yj>o4HA|gCCnw2`=6A%-YPU@e; zqz`)#o}rF%jCW`1IaoMwr?3AM`B)s22~?h59Ow1@oclRiw86`b9J_u@wy?~10<7MQ zNxTj~Oa~gZJkQ2Y!x0HNaW@%{q@<(>3D*e;E!EXgK*u4W74NL`cz{zWAVYY;|LYdH zPAe_?YyjmmT+5m`n$g#t?fWsY#Scxk)Ll90f18T{sU+q1+Golt8eGimP+&Bx!z3MZ z02~*$Fw5*iLDNG01}r?HuD>owPIjk(U}3*FKq}zT>281jd;=~JVM&ngNdtbtA_JTC6lONXuYKbcFJ{-?W)TFhLuZ)!CUWB&Xp z!bhRk2GB(CzI-`8cv$2?23g@yzMfTW~*=(c8 z1YFMMJaZ=XdyK<~KAp`8xVt}cBLhZg2*oOIxFqjDCt^|hXL*3A)aCP^knhM3rmy=& z#7-FTY-G7d5HIb>ENj%RuO9A=D}IE3c>mkD+5mdDTC3WA`!jOH#+TnHARqv#e#JP$ zurSpZJ#hbY_riH&AVhEAm|r(a`-7Is!Pl9*MJm~oj{{MylVc+(TwlaJJU;$Mh3MS| zs`|>xv!M=SeaMyEVNAig>EH^(NWRYJi=|?zNO)icv!I~Ybz|0sx5CS6;3;*vE`=*h zP4P=36vdMVi0G?h8B0IyNfI>ux{2@U8_CQy>=PEW17G19Ug z1YP@Bwj{FK*xIeW^7IChnFV6$sK`=uU3I%7BbH1zIT+_pc0))loBUMHeken&UIH|`j{&Rz>)B?liM9xV!fc@@3I zAmSPYEs>dGfn{k}h%wMEv1t86Yq zfdkpF_dO7EY|La-7}Oppu$c`;A7!DNmzI>Qk;ss*dyAt)7-(6&8E4liaQ>ZUw;8Y8 zc2NFm)=|uc_p&)L|F6a8IkmKEO-#qxU=^l zk#M?ETYk6VhZb@*PP-hDx4e;Ff#bg=RNmb-l4~e_>U>)s~tCAzZ@*OdDVZfTN{C=$2Vl3o%r*o;zmsRnGb^k-NJ`n7Ml4* z7a<{nC8u_eJ1iu$nN;NWFGB1>}c9UBu+;r^0DeJ;pC!C61VEZHjIe>TxE(*o_R zhAq2aN=_Bvo*&)Gt^(5Me)IS}ymc%@S(Y<*#b#fC+w$U~(ZIj}7)Mh3gHe~VI=!ys zeGJ{H@08ejA zn^012m(>%w-d4G6f1{D9^KtKq?-dr$t+4pb%}hLMjo+JG#*X2$o)n>OFXr&@wbyCb z|C04m*XM7PD)^3tOTMm7$j!A9^FEI7LiW-7ijlcHuWiXB3xtQy<2;|Pb4cVT zMC!fZ4&x8lB|i@nCzK%4E=BRmOI|BHB_zAY8v04oK}w64u;xtgILclQd3q9FsZDpV zepe9yJf6~$iHMMI@Km{va&b_`mvj?(YvVM{s(5mJJf6Ua>d%UG?D*VsiKBhJ$g$Hv zraZ(A&ClQ3*x327yU66+rxrSv98Tuz>)Yqs-+;U4&`v_pVpFmiHD+*bZnrcq6>okc zftkbRt5@yr;*!cO6kW-&psxwb$^zsWr?nwp2GnS(b&j=&76FC35GAMTFJ{uFo4*qJQVQlIuWIj3kycwUEPG-?F|hkTStMH z-!BoO{5Z5qr;4Bi*^2wU+8YrOF@dn|mDC7j5Ls;i6XXqI0zfdu!^4B{8&JZ#z{uO(e;X|{_7~>#^+&>8~*G3+7Jl-46 zhBdsIhRI#J*H>JT&cJb4cw2$`*zOE$nNeJo`?Y2GWmTpq+m@}bV&WTdEoAu zn&w~6^3CFK+B}=7UuKm2HAuOV5G_L{=+?g1aH=Gk{!5NJjz!hd&JDZ z_u}q+qw?Xy?{?Kt25dYcVgG7?9b`}e=3TA+_c^!MOoAh*A#BNus`K4|2~Lb|0V+{) z!T)`ZL}jyD%m=S?=i(%5t}2 zlkH4p31m~;FEw=J+;BpclbA`~Ie7ttad2xVgwIy5VFk5Yr!tf!%%uC^i%VRhb||sn z`mWsOxL%5u8Q!c1)n=#|Av)HqBIQcfJ)jkKSvpM%&|q`!%4X5GnXPu6M{!3Pz#Xuh z>`yihAM#p`?I~TJY$dRJ;$($S@e^@bP6UUCp)O@w<%@7qtx{v&6T1%jISG0Q2E6c) z3gIk46q=-e{z&Lw4c2W&sg|EiGS4o7wq`;F)kFI=LH^&U zGdZuavnv@>LF|eI!+AR^hi+z3#OOdPdHXkskRYeDzKZ zxVM?Ye^OLy)q9{rJtF~vNSR_NOIx9T-!Y4r1lLr%ru@tdj{IVb9VvbOTGY~paZtXf zd0D-COL)jjN%5zhu{G7qT`IZw^59vYOZP7~hnK0u<2dM94`6avF+E-(d_<)WGYfts zwULtWsfq}B)}LKUUdvJZ^XF#SLX`y{n7o{4Ro8@m-ZF~ev(_4^2n(Lp()4G4e~b?( zXbEOMndwN@{g;01P9&Qhngh4G?LLTJfRp;pmfHo|(NEx~#ar4veML^5q3a(pnzOM% zbmBzeWe>x;A&Nq9##+KP7Cz^hIls2l{@}-I6OZ!t;8|6|d`66>L@O^9poCX-EHV+; z?z;83KKkwXu6aj8fXPeCIh1g_%Mw+s6u`oh0uM{l!85 z^qw!zmlH?-9^E0}Hay#Map4pIT*=;2c*VzQ@c^ZRI>=y7y0wVL7qmNHo2)*-KP-uFwJByEQc%cXyP>^=hE9kU}b7Xz)`mW@blwC$<99)=#7Y5AIY3^R&Dp z`CHX!u0)_J_s<|Y&EmgLHUA$^SaDYz!c)W5qY|~_B2^@NU8}^ZxV;U5a56RM;B)+e z%0f;K%^}$aABU5vn%ecRTp|VCWI=IVA9!KwV4tFT#$BfrzqhwT^g~?9rd7%HOa0Yv5ET`7 z0R1cYKuYZV+_%cr`y1brmP}mOolsKE{ud>is%-2d<%of7Q<#lqhM}^|WT*IKt2(kV z;dXHU@G{C!QO`=+*63cA>*qD@eUWDv6wRv?d#RdeEpd@Z!TJCoUfE`yE%V<|CD z_oT#qVsXY2;ui=jXAaK*vf05oxbuTZ_J^kI7PYJjE# zzVo{}S{+u`{x~(O8ZQfcA|u;v9w$q=ENi;_lyaH=kAokp!d`w7EMF(Px!>t)$l&}9|ieNVl^WfLTwSXNqd}_5hCe^Rrip-9BGG3{LhPA9kujmfPC$dgGlYZZ{ z0RPaP=hO-WQK=~>F8ew}%y!f1*BX(8*{xVExtES18_&ojngZ^I*(#7Gzqr2Tkbgb! z6?%c+vy}BVk2t=vSF`=ST%Wh$Y#&AC}G^C!*mAyse_D-xuQ$2O_TA}72-QoQy zpZ9d=x%f_qw%s_~(oE@R{Jz0TMb7KLF`E_9+)t%lkKeGdCZT=2d}@*=-d|o~c|OXB zfjtOCq5MnXOE8MtoAFY8g_(NY^$x$a=C}nxBCFz{MZ$+3LO{Ce8&R!?fd$J zmxB&c@mp(8Fb~J&bhJJ1{?j9rrFDIGRAG!+*WDjRcWpA(@O9JO2mC#Axvc&|+}_NR z$brC|k6!fp75f!(M`_=x1h1y1@_d6E!5Lw+N5^h%?ES(}6L08Ho_4Bty3J+(lP`z_;UW5TTc#|(awo9`cFUlGJ90lp~^O#ZYYlpr_HlA5ff{kuKELBJLix;Q(Sc&Eu4_DJ`QCL}F=ft-wT+W~k5k`S4xI=34Kd z7kAmsQK!BJArV1L_R>AO@OULSk+!$rg&qrV&ea$(*Ss;Od5 zHtqEB=vP0bnK^Gy{mXYru$~LUm^I}wsVnzW!IHzZ+W5nBwPA;eYFlEbje;|$mTQ|w z2`(=E?fV(^GnHnMzZz$zFBM(;{iKpMYl<55dF%*zk(u_hecoFiw+V#Uo!7sI1x2E( z5YkUNrLR`ja6Ktuy?wmw(6|#&{B;i}3Iprl;N!;5g6Cq7(|8NoDRJ#qg(>;lr>Q*d z(j=EWIQ)|I;@@P73hO)*E*CXs!!Aubn8^Kt(_Z-{4-J*BkE%3~a$7e06>LgikT9^! zvSrVvJw)P5>PU}1n=ap)I5-~KXGbBATvt7MFbH|$NG0S+Q!WQ}0<~(Hl5%!G%yup; z7N{mqI9GctmRKxftybTxrR0^;X{QytFaG*sXrqNN)J{xEk#}WW^1n2(Xa8( z^y@vV>?idZ63e2_M!Q){S=U)S`u5bjwHls5ri~R(QK+yGL9cTUC8gIGXF7)f4~(@d z9#I_S(}t;6?Z(2y6b8kEK(4+wKhS!>kA;PuQyL77Is5LCP+=EVuR7K>6dag*%1xE8 zIiVAlDqsd5Va+i$v@KrkO95YuJ_1axUaDSFwrGjTz?+bUuI#_Sy!Wenrw6>$)5p*1 z0?jptCgA$iy37KpnycB+<95`naD7m0U)(s~o-K*tgdvThprneX7B_q{JXK`gP$cdt z2GNc!)SEc!6?Yy0y!x#>EmG$X&#kOBJ~LACll$nkdi^kNOC(6l&B?_e<3rt0W{{6t ztapac8T*G#)*3q$+18~#6bK3oz={dy)Bpw;F=3pGOjy-ugserGw{J>B=*0KZR&djznrcd+o>S05V+>7_>&UhGciSa@5 zxDZPl)8xp+Nc#2IaE2~=1V@?rb+NbKG*NYd?uq{_cEg=NyYD+i7LCZ6jfjANinFDMx$%ER=W1>)$Dx%;YW?~EUr)2*wTiw_`J^fSh(#M z#-u*-ws~-$^o2HURSZz$I}M($d}qN1=>&x{wQp8+zL1^q@Vu*jS2b1Tm8f^Ye?rXv z@d%j~OBhcmyR7tL%3Uum^44N~=cXaR@rKiP56%|*`+3-4Ryc@g=PK5k6 z&o`lO3i^Bu;XUE=TVlKV<~Pzbgj$`E=3!Y3z7yP6HS4@wJJ3%OnwqXVQ0~T;z2h?G z?q4<`^PRqY#l*Rp-O&j#7bjQRfQ!0Pu`^Y3b@hE$A(^*BHMjBQb&2Zr)tg(x3Y_|% zNTO(a#ILpPoNcU2piww>9V3#09{X5*w<2;e4@__A|{7gGgLd4A}5w9As)HPI~XBW|?QQ zeWsh$IjC{gItaU8cjmBM6X|c`cI1>=_v3cUiU0u|%P1BLU})Y9OGLS?uKX6%P>O(^ z;ENE5LFKdYi&A8gM3Y5Nua>HH-)`v=@PiWlWdvUf)Hq}PPn6R6N^a%UlBkOty3Yr+p2C9biS;rM-C)+6yfeo0stndh2@?tqWXBL4 zy-6C8OS!Yk+&)s-##;OK>6X`dUL#ZzU-t2f;%FbMV1uQtdU7LQX`^syW4?No zCQE0n0Y^v$t(6p?FFn2qIEuNIe4KgorqYH`BmT~6kMLT&o^?#%3>{|5dP!D zsalgcsjNtNr}LROQv zjS}+nG_Ts$C>CnL`C}^BTUDs{o#;PR7_iugqy+8~fNk`eE>ISY_ zk1JX_-n^kkS~0N|v4>_4AFlYGgJYI7Md0@*1aH;i$q2Hj_Xk`%Eu}jxQLh4Go3@{E zlzMw?dV2)EEkHlyxC!z6A$Hkw+;E}ZHj)B9@tqfd5Kaqa=B3+~lf0Pxb#!Vs*yKGp z>s#z~M}{cH4+e{`g2ns#1?2eU#QPP~@TOTB<(gS^c{m>o4o0N#OSxXLo{PQzJXeb4 z)hAaPQ)RPzU0!#QN1S-O&%AX^+G87jlV`YQX2S%mKYGWDkLG*&w2XTUq5=SUrYWluyvT zGwDyNenxO{k4N>>WCqLYF;Y4`xQ{X<3|h3bf^?Dg`v|7JAxM*r;Avs!b2^DnbxR%oH__k-GuACu^X-4C=c;J6pYCv4cog7CKaL+wOMY*2Q45I zgrKFpyW15cK)LP?f?$*H-HcyU@~RFEy)**;mgj_U(N%wbS zFw`;qE@YVg8bmOR8S>aPKn~SwWzEdqX1EN*^pM@yU1pWtzJ;`c3>Zxrbc#6+2B3qW zx|ykXfXPZo7=H51v+mO$9U_%*7;VDVaJYX`n^e}z&%@4a>VdhgZFlc@2tV1v85|mN zBia*PpeX=(Qg2WYC8h9BPqXL%_*EK5MqV0#``Vsut`no)*~A3q=6<5p{V^zl@Bq9N zF)ffc6^sLV@*N#g!5*xv1C)4-{Soay_Kpzj;%-RImKHG_uRe!>@S(ZmPu*koXz}>8dPh3r?IHMzq9|i(`@=EUOF2%ZRI3wq(2^Z?TSa@R89`3DA~Wpx7S{ zA0Z7`;+D^3_z~{f-tpm~_(gw2OibsBXiJ#ucr!kgeN!MKH2*LVADcwQ=s2rPe_{o| z?Ovz?I|@iqQ&S`1wQU5Fn3w{TE^zw2qnX>!xVm(l~bBpVx>nOtJEJu-AH`v+jZjE#>z_9Vf< zB@`+RBVtR*oi2tP8sA6&VNt(QIGAYMe}v&=z0|=NZZ>{#Do#8(Vb|EW=BPH=V>_@! zGcm#A_@?&LPFENA;kXfKfnOP4nGvOBsodZ*@PIUc4GlwbCp3Pi3?PIIc=Z6LUdZ@=%f(p-tY|mx}F4WhmxZ*bc8W{Rn`eW4r?i-L!+R zsf=+mNf;1EHrky1$Wfsp^X~KI<(dl0(DN(zF9``F8xwUJA7zJ_9L!{|?9sMkLAJ|i z;RT8v5_-1K%=IxqESr!}W-1eFHQHq@hCrmByXFFGwzPzc2{7i;vzdPk_YD`M2O&Wy zkwh?2Tj>0(VX5h0QBdqKYiWB|`v+z_&g6~i(~ZcO6fnjcuCenHe_lr6@dN(6(-MlF zbvK6b!t^MvNGuO>`S#;p-#od5*YWlAWHb^V!fhlgFMk50MWY2biHv$-FDDe#9vi>H zOP;wn+7|S@cOwh3iWqqw%c0By5oUt}{!d8+<`Z{K@nJk*iaU}&{XVwc%T6a10L6Y( z@V(w|%83LrOGGeZ(Yn=T#V|4{Nq6mu=WI%HP)q?UCnrsNuYQ$LF@R=REKgS-qg;Cc zr_&$Aw(*^KWy9flu)?=`3ITE&!bV0=U*D|YVfy}|uKX^{{e7?NvS`l55Ni$jmx2c% zl4W^zuDG~=fkF0cFTGiv6({KzrR{I5JQ0XdrBDWoPAeedTRAf&1} z?MO#eCqFZvqJZD=+ss%VA@5YW@#?HbkwIo;=`Y_b%4MyQLe2i>yz#X3rnR9sl4#1A zg9^Ku?3^6Ex`n15pu&_Vk?N4@z(^ueeky9b_HwqwG!fuT1J&iJ7%;;c8D6s@lPCX>r*w|Q~OG*R9q&rLhP?rC6 zwhbttFuosSkzQZ_&BeXBA~+bAzW<|xgUoq_-`>y2X?aTtbdGuehzpJ~t~XC*Do0&u z7#hw4Z3`ZwZrOYo$J2(?9oJqxh?Ap}41Le(iQGPhwSmDao$tYNwDx-Y$#3cvrTvOa zB|mya!n!*Vu6OU`Qu%rr>WPRP=Iuw8+lk}|Rk;rq;8|*11jE`4 z*Y)u+p5{t_4gK;RgbV_mSp*559jy(|#=6DAa1@V#R+*&+YPU7q%O|KpaX~$z_^z0g zb=X-WYCqT#mbY;V78Q=j13uTNq@<6=#xm!)U^1T_j^ib% zlhmxsRR0(EDlXI88=fb#rKS^L%?+!lp=_T!g$&!xfpOYTd1I%|Vf3dStuwv}DKRH| z*Segk=`fi1xI3(N4F_-xGr}`k6w0mbCn*%^SYyJ%3B@* zvgp`70B39OM1dA{)-Pf>|Mo_2Gj0swwYNYG-m-2wabR$Ug3=X9OBwud;nj5V9Xp`2I2=13hpBBG?(E$+s)8YoSd zGDQtBhB%AN7(!q2{GK_L=GO#|Y&j`8!^|In!M_kC2YW|QvG(hggk}uJVqezsh-N8h z9(e&jW%*MT#sjj?C0`tKB!UpeGt<+Hd*2`-4=Sws55Ga&iCkTzb&?ReObRN?Q#7n2 zN?MmK(eF?I_R&DQfxP+LyoTR=LX#m+7*EuAUjQ7)11id0YJ%tv>< za%;@Ai!X7=)(&f1bWP=!B5OW(b)9xUv79L>v$%^rsC75&3c{Gu+trona55Ts#`Rb! zHOk*SQgaYCHqJXXHlZ9j)(9fXQu)!}dCl-r@lLk4pH0#TO6ySTc9Zdb{eH<{qwzPnsXdHWkDkI{%ACu{`3&l4BQ0cbwfhtwk;?nG0(QMA z5H8d?2w^438dfnHtb6KVe>oD8y2^w#!u;_p8?Y*S5?F?Z5b7UggSE-SiuFSbGSlX^ z_R_Cp@UDgNPfJwfu(&FL70Qm1koZ^gM!QAWS_}=@IVLw@-tTsMge33E`q(6hKqUVo z_c0};?qq9XXJtlmVSze})_xQ@s2Tvu3&0bOCQv!EbCxi=%*db+?f9}g#b|$a_l~N= zzW`#xSRRnmy17k3J{YS@sz7*jGKb@sr1+=VHGn>2(l1kg7cEc%{*t!9Ve1^3>|fcE z1Ur9eDZeDb0zu9&xSb+YKG2f-hRL(3(@P^WE`lej$39;_wJj1?ML9Qs$3FPSoad|g z_Vyp2WMt_2nOuT+etB{6W0J9mJM(9CB9R%O-i`<0*C;JkdiJsUP#?c20g{+t48|E@ zLhYO(4C(^@1#R(kX&dt#37Pm%*MOi%hfySVkqgWC=7;rNO%M&qxD`DMKR0{iWTLo` zoWqARUi-}L7({oH8!y5YP&pMcMlAau{N;5QxGYMS0@y50fZU6KTTwuyHghWzy`gmL zq@V?<9<8O2lYKusR796sresz)Lar4J-!474Ky?ExYK%1C|N1j*%(0z&TL-BZL`Gr( zE*?mGvNN>gHkP!V_W$^V@LxKvZ<{)oQ#BZ^z;~2M8GyhoR~1q%icFILR0oB zKj7JJcpW?`YToJr1FNEj`nWOyS?;v+CP5Y#bxW#Q6M)HPgssobXlHaHQ-$xcyE&sMIXN>o>bjfqQQVkn`~J9U<*L*b+v9LTul z|MnLAKMO}2784b9F*JPLqz2F@pttpYI9J?IQAp@dOv4Q+v%!0SAQ=J3jo8?>&?CTb zT9G4AIlQHZ7#iMahJyfyImpUW^1to@9=bcAob6Z1C-ZQ4oW;tPiCP~Wk1nq7g8-eP z55*|JRg-mf+8UeRp1cjrVr#F>OXvFgW4W8_=8jp0&rW#t2|S&ztsgif#uhn@o<4lN^N zz1Pi5rTrp3Jp+h}rtVJ5D2R-Rc%$`-npzS?pBNLP^`=Ex1TbY1)OF4x4n%O!rRk+2 z8=IQqH`2dFM#gFk0_<6#W@#%RL3J)88>I#&GyyXX@JhgrY3ZbV7YL2v@Y`h{sH)re z3b2uhi4*-XNq;ec#NG?@2S3!OO$yh@w3E=O@PqX--VFCz= zIsjP|>D397bJ@(W1B_VtZ$LTE774+4yVf;<$kF)piCIM#gsiV$*SWJl$HHQ@8fpgw zFZoy4RLw0dPmr~*c)ni#s~7MXotWQk=*NpDn!ATB`1*Vx9X&@rDk_?_w1j|W+vJif zXR$~omJ#5&Y(V`B5OmGPBzAVU_%8;XGwTLV{jL-?IxenQaofr2Xj+^Yuna6G%eO&r z2S}~|dXOWYYy_etxScMB08HX~yq+J#>^cr?^HyMP3A%vX>6Re8o8lcz&hYnC{oud` zqT&H_?_Jg#YDwsnNaBdick?z7g3j(}UuD1emVB+Ag`GWDqlolSDw)s8sPqRQ@dU=k zb6CfOhV}q7eoqSjC|qL~q%NRSb;of#wEs+B?bUyUL*AXl5!uKKb4w$6aV_AzJsR2H z1OAiE1mYYlBL8P3p69H4VS?As3%+}&fY+h_@N2F;JR_D#U;gapuPd_a4NOv23dyew z&Ie0U4i2RN(06+eaTP-vUw!#j5S$Jv;kHGW(~wSEi<(Dm9MV z(o()ZezXJAVQ0cq63!@wIlx-qnOQ3d3kwQDuk#=|;^F3A2gIFOuT%bqBJ3-FeEbV0 z5oWpkR<15*mmJof8EGM`CXmZxV&+e2pOl3gAF-8cd3s{5E9J#ldg_t-e(9p(aNZ*5 z-0w5qt>@?cvX#l6vgO>9#1aX{G=SRzlBUk_ne?DL@`g4bb{zqy9u#?;*%o#MLby`V z_SmGN@V5plc6)SrE#g80A(to0WN*NuL6L=4)(NC(3!C%01;6~GQ314pJ;|1}IXRED z-vh!7$OmTP;^O9xmk)7syJ&PR$fwURHl}v87=(3SJvt3}ijgzff<#)6(<|)9w5&nE( zaIDOltDv9EEC2ZP6y&c6sA%yrFfjNo78DeemX>Dg%Q_17B=fHB1EMbHhTGqbE8EymU#^%KtvH&I&E@Z4)pZQ6zlQxq)XG;*&2pN zLQ0Bjr2u$n97>6y*f5qe2zPQU18b#saUU?YJeNC%=SGVXD=d0c-SO~ZKsgGw zagb-Q6_@R07sbg@AVyQ%op=L#|1-y|HIxJppo)Q@ykfUcNQ;*yM54F9zaQfvAukpo zA(&EAPWddg1mO?ii(dxSF?<|ckvrX)F{K*LS?sHIJp=%vsd|WYb8Hih(d~$V7FO*T z=<3P=I$MWTOHK7h$90Dr%Q73Q5$Y+`svAQ&rA94Jq|y7&kN46=9MQ5E*%F3I)bao& zOaN7ZpYN16vdNVXzu;hJkGBG!BYdG;El<5fkV>+DJ{qYpCKYAP!p1gR>&k|KkB7H2 zSyKVfP3Pys3p1}oM4AA8>Z;0So2yY@boZmRLU>V8<01&9?*utKttzA~9UUOz{Hq)r!e-QL9;)9KN{AeK z4#+nW2ckJA9CJ=DcFYuWg4f=qfsVIF?V5kggV@224jKUFJlNlV zA)oaT1hq!y<>gu2dkh#ZQ&GbAAtXt)ah=Y~jR8IUoemxHnm?PHMWs*LH3evB!rj`k z+rBH^A+%Q7!HpUOp&Z%0?bFlAbzHU0nSf8v;bN) zu{F~K)|M*PcOgYFFrrQ2du-4tqpyE+4MBZm2S0#W9UTI^%$+-GkN+FVt~9i_WRBkM zZbPe*sicn_P8k11!O}i9L=VE9aynSq214YzGUW>XL*WC2MX%ib(Z2PPv2bY4Io$9# z5lbd&VQc(TO6h$86O^|zL%XfS@mj#$33BBa>&Y(kM*9af)Ya9Mau~h-s#6_ukBCV? zt^Hx>g%y+`oHB9qg#M|mmUM1^KMmw>+%oCFFSJhfr8R#)t4(ki3`nR(6T2V9-8VGA z_@631`N!_d@G;=n2Y22L%KVpp+^tVD^xp`D|B=@B|4oJZ`YC`J{olOfzphFkNd5F| zS#1Q$?}0((ZU%Zawc)Y-20*bhlD&y%4WzQs1ifAG@0gSr_0i(`WEGA7~k^%UVHj}w3=&{mG9l^*AAPx3J_?f&D(Bp@QPw6?w}bt?Uq z9v)s?j5xsMzLV=?<+33q|3lq?vwe5IP7R5Vi;tfe7dKODZ?j+2KtV-S90@FzgM)){ zw6AS6K?h}mr~2Z;Z@}y+I=Ylh0aRRle?UZ7PhJJpAUdJfoL1hMI`e(uu@UM7p!$P zwG~dxh07p``Zb>eIPE6ONdTg`q_h-7>x2e{fG<|FAMGjCJX{$=fnU^DDNdy=%xPz0 z5!jq8Dggm&1VN+0q))`YBm;8;?d|K6OESXjZvzig8%cPa`uqCw69|PkYZG(L&{Zq8 zbu_ngT=+ST3X)S&Y;A0&5w0>P%d?YTkc|39BJ=kPl&P7YLemr^v@aKpR8>6_xHUA_ z1v}ue-+F=lafppM?Za5(!3~MS{O&Bj+YvLE4gg_~Tmq*#6+gNQ2J5kDa!4{bB#y;`9!c2M6%d#w6IYkp%nCW4 z0GsFaH70pe6UpYVU4Kd0RubtlLy_)&I-Qag5HOgR;xR-&pxc3LxPU|B4$-T1g6 zsMt{M@r`3$a29$ohhhhn0_pLU(JS5>M7h%Z$Tkl!D7vA>!f4EZy{W=tIKf#oM-I$X z1i87ZAZwXDG{}`m1FfIZGr%nbZM2&zfmk>MVq)(q%?lSJ%ginXAGi^Cc%V zv%6hV5NCOOm(@8hSZ-;!wxxi06bvY~wl*ENXF%`w*1Fi6f;LpJIaV$r!N6d{P5d)4 z(cyY4UOu^IW1aYy@y^8z>@-=MTmg2^KPlKp8ugb+Kz*VKlSWEGZXZ{8vyb9Qj0tGE zp0w`@IjhG6NHC<9Q0mces2{ttBU5ND~HMoCHYxrCZH6Y z?tblM1>4_v}ALQ^XoD zc$6;ork>8!+pryDy1BWvw6@ag?a9bGs^-Yxi*dZ<`?dPJe%dhsz}J3GbJEuSFUx5{ zy!K0Ogxzr<Jj%&hqdzB4YS1hUMK&Nz(-&KM7bKWCa!V%T zG_qjsoC|neBZkVOcfaBb-t!fml`Dy*>s41<0|pi$YIhk{@5QCPKai7?XYBYGKN=E6 zD}I9rmTXGLYOqRgY=&9;bVQgk$xNshq~OG15V2V4 zB=dwis%IeSpJX)D3H7;3AweRvu&858p2#XOGuL%#i8(6!GxT+KEM^uDjp_r=K}wjV11iN)|9 znFSHob z#W9K^2wj)dj=>*UZex;=<7ulk%H1qGHcOyNK~lcE<+@7v!wf*SeLKZ!Cb zmoyj#cB{Wz%jjJNLgiobP8B<7T>NGFU^Ei(CM=b&09P?GPVD5ef=JnbKy>Y^_0l>a5thg3=?HL>S=P94Sog?V%|JOw z!nZz=_=6!4>cM*xlLxwg95?%eNE9BY*iO3_>XiRju%Ez=Li5OGV|hF`)JiCNoO9Wll{gMsuCDN4GLtOBn{gouOJH zp+mF4`lTy6xEMsV){CD#-<;jt1Io@M0 zrg7`Z@lmIIri(ue89BN&$9$Dur@w_n)Kt}a-BO)TEp!(Cim6LrjiIZN${Z34iiC_Y zXN9}CL|Hb-Ocj)Hg{z2YIO7d!nekM+_1wUwfNfO~uR8xx3cD%)lC*B&+KUe(%biuq zfu7h>e1FMc_(=0R`dVaZRoFCm?G|bj-6J6(cHA0}LtM(Hgpo`Ca9$q~c?X+q5c>MA zfBJQA@G@`V6tQb%Q2x3<=KlZD*|~=^`S)?$^YlwSD5px2lI5@=B~%u2C^o{#oQgCf zgd9>%9YirC+OT98_gJ!W$dd5bFQ+N8C})zPA`(rsdA{p#{kop(`Qvvz|Lxkfd%N!M zz3=_~-tYJ4{dv`#50ur$D(~p{xF1XRFHMLvN8Q0RrK#d13F+aw*fCkuk>}+k-wAdE zA|GM~41XG`MV{FlHqkDGTW9Jg-Qxx60g*6)aYy}wfsw(hShA$q!cy*%rTlQOnrh9^Cm8yXvrvZ;$M?2T4(oI_@5IxJ>zV#bC|P^qZl zxb9z}0-oyu5KL{Co|;kDotrIeLkbhGSCfAxUO}_pH?LZ*(l=XKS&5xAGjp#IL^YZn zs5Na^jtfXHDM3bAY=$MoLc`ad)@$A9tB;%(MVIKrde|6D#1P;!94m5JWtA{rtG5pM zOcjAl7>Lm(y&vxB&eom+r8rNVr;cA zJ+LK@(a>RAxKj-?PF9qalk@j>XFEEnY@5X^`*45$*9X&~ZI4a=0s(ml7A9QZbJH%! zke=+qV#6Sd7?lNu{S~-><{AS4v)@ZbOry`S0Ri-R;KOU}g>@2~mkyvGR2^lvi*AU0 znBiRg!^0>f2T`|%`_25WQeti#x2L+?2T(ky$xVYbb)X?bm*Tx07m8Z4pxEs(jG>;fZ*Mym+_3auJ?hXx<>_9(8A{Hc&NK<*7&-7o!)G=f>)A<&mlrr;Vl#J9 zZ28rkjMJyRo@#HBVXZy2GVP|%DYO3wW$F~nSCthbK~xvuh6W!4+z=+UZZ`Bu7cG2h zfaE$F`sp~2Qrjq}73KbO6*?**vG}?sNKngj_~Hq}9g#;5RzZm7G`kz3FMLWKrc6Ut z6XJhNQd4xgAt!gns=Cp!d&fJ2loQu@(%sU%;R`SDoy~*4BEB=Sh(dz%t7ZsUA&;xsg>xW;xkew$Vd6XO^lp=Z-H(+ z=!@q7m<*-k_HcWaxUleRH zr)GGKmm93Bled2EzILv@BO7^3+DqxWghDXC4ydd( z%vBS!x`B$Q<&5Vad|}C34Q=Kb%n;2J`n~};(4~E9JVm3Q8R)eLx69d1d+rrlF{evv z*Z3>D`-#h@IQ^Uu6{}BI2t&j+PU4%i0PIYA`9azq*q_AE{*1fP!l@y&HUabQ=oVwb z5FX&gK$i173EMWKFqiT13=sBTih3ETujttJY&3g@ z9gagPb^vd8`OPvad8EW3>>x8UJp$33%n3K~Llf$AJu(@FZRaJggSJBGVNT+1_?~y* zRu9UTHelPA&!Jp8_}r2r>!5%gz80>~lu3k98p1NVw2ePbGQ@#ufSU6Gue&>;N;`T5 zaY;}*l7@ZvQ^DFCpYKnbELI1Ed4Pf}ZwC=w{ZzG}yej`@thjg^=-;q+;}6=B56-^? z#wpb>NG!k23N(_pD%@x5W&Y@4O3JtC%-XOB?+pP8H~-|mFC?2XV5Ca{p|bS2lCMCoF-LOJb&Xu9mQFa zC563lUKO#RL@ZNz*HXA7KKB_?d`#uoJ5ZHjqzLx<^*8WK^RiS*d~9sAf*Z0*fg0j- zZ=U~wHzF7sT=^!LIHK>KAIGkw58KbbXes!lMz^@gxlu`&F*zKq+*&YzWNzJy&W1+8 ztphei%4PKz30=$Pe6ew=VQ0Cc8|_q_@sLW#PH?Ivr~}#-UfNW9Z(SYzJu>s1=$)l> zP$8(Jqtkcp=DBr4qr7rT#DV39y>GD zvdp|QKff-n{;9JO*C>liF5~cmE}9q}mgvH@T+=4z+S#8Xwq5MU;JabQ;HozA@|2Cz z#`5zVP-JiVqJ;;E{>sypY`r)hMe}py%am}cD~ga|AE`8KYff)>bf<`&(KbqoZO;BB zP)())*S-(=s5ZoWhJDkZh}^Mr_Toxi2~? zBJkC7x14ZZO1s9OKiu>HRegloV*i;2w%^HC!0c_Y_NmGIkaRkYPW_dOsCCO^4j zEp~+nU@#%l-!sygcC%(Xq>L4aB(l&a6ghhYdt{5T!czx~mZrMpl2qgOHxJoIdwF91 z5r61g5P?qjFH~*BE31Jhfk7}&%QY!WPFLrsYUi~%V0Qo>KxtHtRfjGEPuM>q(A7OVi|A23sK=C0AQLWO0T8dd?X}SiJ%VKm@`aI#{LJTQA3^p literal 0 HcmV?d00001 diff --git a/website/public/images/example-form-review.png b/website/public/images/example-form-review.png new file mode 100644 index 0000000000000000000000000000000000000000..a0f69452799cf6597b32235639e327f08bee62b9 GIT binary patch literal 31930 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�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

yov%tmJ0dPB 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 literal 0 HcmV?d00001 diff --git a/website/public/images/example-security-handoff.png b/website/public/images/example-security-handoff.png new file mode 100644 index 0000000000000000000000000000000000000000..d1b06baa44b75ed96032da2a6ac85977f3323492 GIT binary patch literal 40590 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 VzE|(xbtx2nA(p%^>JBNj^dGi~y;cAK literal 0 HcmV?d00001 diff --git a/website/public/images/example-workspace-deletion.png b/website/public/images/example-workspace-deletion.png new file mode 100644 index 0000000000000000000000000000000000000000..3c993ff4c9584fdde992ffc7cf647c9263d4342e GIT binary patch literal 35213 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≷8)Ug?N|I&lG4hd6C$^vYULqZc|Zxr!k@|MW~HT^ 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 literal 0 HcmV?d00001 diff --git a/website/public/social.png b/website/public/social.png index a2c7127fe7f3412881c21a7d785a08517ef9ea0b..31f0a63980a1d370e17da228284db880c0e3c34f 100644 GIT binary patch literal 65396 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 NDr!Bed}{sfe*hoaR?7eY literal 62754 zcmeFZ%1G)O2NA`Q|olt_uRAl=;|&Cngv-O}Ad4KdGAzrXvw z|AFWEJTLBfbw20J?6daTYp=N0wI@tnRUQ+K1Pu-j4)cwIj3yl1vnn_^1a}k!;7$k4 z5jq?kHryK-NiEN`qm}0-db&%(}go51&N_NU7$D|If$D=UT@ zCnwlc2DhWz1@RrP0u-dXU^nL%olS%r&GF3JeJZJQj>}eyBfVcFs8BF5eQ%%c-A`6qZO2wGE-u(Jj)&F6-l!^W zIOuBW_&v?&LXt6&0`G)qVm+VI7Um3n`Ft1spY9#!R$K3}$UYFIm+z+WTMV&qv`G9@ z+-PIA0kxftL)%k9YKg6_3N3eXM8eJx^N{kDFv+fsjg3y3UaQmPjzcCVJAVREL+IUJ z&6pqTl4CVV9(k_JxZM)=#6aPDRDQF1crBEhn}hmy+ztt%p8+xSt-D4(6Y)sBW?U*( zE+0qm(1IZjetw-}?XY^A5fadAYIcW*`DzUuoDnuQ|A)th-b(}L30btJwAGlwZ3hPj zn>y%uVdg5>zkNT*wzB0St!AvEqy%(vw@XS&+9V0u>{b(NdpaoHKQ4dmF^*;Vl=cQS z>@9=ZhJ#u%ck*9J8tZk}{30iKVBTk3hteFacRv*vL0eBS+HtH9J1UXnSW}86cD-iT z)wG2Iu#;0Y?7kf~WyM>|NkmB4@ZsD{xY6>|C|p9pe9N$5ZPY@cq_}uLTb^=cV2C^Y z)Ag6<0&AYMtvCb(^(H6F#<1IN*P^Vdv%rS6>+9=PkKdnrclm8+BWx=gzyFm6gZeJ` z?$r0<>2+nxN&|yhx}Yo-o*wWp`H3Lj=rQ~GdVUo?^Y-xq`=q&+M@33~0+CX0IF z&07y3bwPe9UxGuUDGUYu+j@lt2M2TIBNdfM`&LHk(1M&k4j#AvV8Nmkv-5*=)ljj+T$wb)Qs*>5y8*7&&hrdUx~b3n!gc<3`H$ z+CjZ3_;UvCS9#}g%5UYszIbjTkGKA%Po*X0*D);Lalelm`eal_b9n5@^o7B6DvQcX zxri{}{or?54Mc2u-Y;j*t@;`=#~OV)nfzg^yS-&0FDEmSDayelW4sLe(Kz%biTZe zA`^#B5K>O1jL7a9``q;=dVAmdKu30wqsg`?81c{!Ne=w(4;%7#(_m+!SOR9-@NY(4$u5n~JrT3=CQ6dMP4u{5s!V)hWY}iO198!URLt2|ite_2XlE z-!|1k7AB@TBIU1Yza0QiP*7L*mbUZUydUJ{m6GxBW6oGWyd-A$v^~_fpGJUVZA0ko z_qe}qw1X;7vl#FrqoViA3saU(v|;C{fDt;pEzPCLX%T4+Mle;>lyBM|gFT^PqWbQw zB3t`LuabMu&hHv|KV|-ay-*`|@csElM|Ji_9LDK3Io*3XRxdc+n=9yF{?NZRcUqBp znwK5x*LYU-m80!`Lk8-khng3x7%b%kBfptwej)1ekaP}-yYsk01Cf|A+{C6VrjS;) zQPT+qt){;=AB}E@$sk)2w<|-F?UtI#i}Jqy)kdS|1Wh)F&JH zHCn&NsPHON&#Td)8!23xb~408VA|&DlB3-~{hgIC9uc+#di0Z@Ul3+c0)E%+TP1?O zxe5|tHEE=t!#&n9OXZ%`8aXkA*XqXQ2hHN%WWDl`4CxxmL?k8wfrc8n3~fE5k_ju% z3_X*qZScjnZ))vNy!M`p_=Lu)WO-V}CAKBcor}v^)A7Tn5^EC%4Ncg`f z1*T2s#iuOVfWCqc*ViQx89bKe5`ABdjW}%I-a+eJE3hz$|12~OfG)Bsw1_BQ}?3Pw`$WEiP_%n)&feA02a?xaP=q7eJkui|kg*T{rRA5AJc zYBqkOb=-sCyG=%hcXYyjcMw`h>3`;5AZ2X4{)q9sXMIbk2V+};@nx7X=w#v4?_vMY zvAr&XR^e(UixHM<797S>L+Da7_0&B_tYb(GCd0tw-qy5#aeq*mviq+ zT=T!`_xCpdb>{cr!28+3SLN3QpQ>kFW$)~K?5em%qp7eTsG!~&SN43I(e*z+@3)L3 z8}#mC$n@=7`MpozSt9lBy|%TJ)o9uK_NTM%0zO;6hn*z`N^iTq+WlGUm@_{9i108z zN4vlKo%8D!;g0{qHiP}al>N+i+_?R^=JH4*^ygBK#fCz8n#7S`k)z1$cqlf;A&r(W zij(@gK{6~XwI&0x>#}Oxe%0k>bA286f-44-M@HOaWddA$-@O59dvAGgKtx%@Yl;?^ z-PLp;H4t^Zjk&w%g_L1HiS;V|T>02>RKLH(75CgtzghF)VP*ZbF+La(0fi#1Is}9j zHa|~D)JN(X_=*D6T9JEw*cWo{#EA7rogF6ldz4AKNjD1(ndEBX8OiBhM_bkYy0F9S zv#*h+N=&iqIKqbaqoS{GDCU(;Gipq#74t!lj{@#TtJU6XCs^XJnAw+u#a^id45?s9 zj?#ngW1G=axphAwZn*rhP*td$7-)ptP{iNKMd=b52oYxkoj{oKd)yT86KgY4UvWu=u=kVM#zlw;RbIP$=nKk<^3pmYN>LC*Ak&v$gm1>Ibe8EY{FVTy}56bzSmuX8fo}l&Y#`LY2^h1tvau;si;KOs@?)Yvf?Q{L;kYIeXC{|aK4r)Eax%!JW-iM zAl92XJZ@wnblLS-rMzU;(eI&eBc?YOqYwXc$lF7>sgV2?zfF4}O#^4P8#g)pNG#kn zY*_A{f!oi1?bsG)3Tc118{S>>)zH#Z5EpnW?W{|$&T=>~{?oqy8YX%LIc`|?*|rc# zozc>Yf($-{#KlGWT!Ny(jKd4z{Y#0SI@=2BKw zbPNpMm8gh_T0zj(JkB9g0fEio0qZ0cJ|3RJe zq%MQaq1H!atd^(QB?7+Yf`S6wHJ>S0T`Ft?KPGUhy|Qv`ZyZnuIY(9(w|1HoIa;Oh znK$>MX8N7?_GkJfWEyw$D&=dw8EfCKU&;iXH06BXI7&+!kHIdU$&fYnxx#-WYa9#m z_WLXS=K${MS!^5LgC@(&RaQo7cE63=^WtJAz4H33s^69VFk5?jKJwO^Q7{nYsarJ+ z^}QFgbtha{GuVWMOWNAn^7Gvu=FgKCh#^S(qgCyRL}iQH#lE|sC->fUPv^LjW<41a0QgaOXh zYvQ?56Ic=+-ebjmN&mFf(n(K4gNy5Wii}I-D*OKA?iL90c1sS2(xIWDYpyG9mpi|3 z(iW9&3JxpRs%>?>WDDD_YjXyC72kw~z4Xv^^#1D@;SjA*PPiARr(L(^&%aN8|28yK zQo@|lzlRyCu$8UOD1x_YWK?7MOBlBNYPNhUI#c9sMqn)={>SX}^rXW6`Z|~CvLGl~ z$Ty_05ZPmNJy%}9aYfTe zP00P%9yKmX!|}W%PqwHf8V<@>7T@74xfrN+X(~^X=`SYvxki$TEPhn~C*5m?hO(Tp zqN1e6h(TP2{VV9ti;JH>x$_lgdXpi%2$n--#60<$?=_q>ykwvMdEIA8*({w|9HL*$ zr6pDp%xoO@=ZVJqnBlp#Qnx3hj*E9%$XMjetjs#LGwa#war6;|ugEvb4h~6yDobft zQNV7w)&u5ui0f{*C=>Ec65=vAI>N-ts#oVe5)c^5`eH)OySqgD*`M8#hRPhYVovmN z|J5)F2}wED#9Si_45~yE8xw&0l1!Pyygy>0*&gghRr?kwEm<5}s@)$!Qwny;F?lp>&Fb?qAy)t;FDI}Vy%}%g11e7q>^ZtqB>fIMv{UBn?c9I+ylq+ zb>TO*WhKR4u+3;x4D1JkKVQpgr^a@d&eybGYpMyyeL*f}V5qNzRD&4$Vo}8Pj4A;B zOC|tJ!@)rWo^?6cEw{WcopJXa{q}=p&}4Rg-s(_2@K}0$?abw`&Ywag<|EwtDR;>&GRc`cEi-X{sYdZIzLAT6tv;!ja4|Z#@9@Tkj4X%&N2M^l38a z^WJN58GFSk@qY~YqwY}6kqDQ71k_*NupsQBPKXL*ASaF6I)~?BoBbR{jWHc?onz1c z$O$EdV!QYH^uv0?3GVLhR8s_<##UQrEghDBZU=au{uuo5;gcR{)k`BbmeT+Fy_^m~ zD=8UK<55mH2lzv>wAxSa(+mg%17r@ZwFaVMkEF5RBVX(F*;%7&vN@#(%3<6;eSBs^ z3F5c-U_pS7pL&vEUpB~^CJ-%vZBw_1naZE;Ju38JoP-Ai*`@SfR^a37m669q=88C( z5m+rNFU@U`mXadF3<5y9IiQLf6XO>o3K%!w@Mr%s131Huc#YIw!-2FRSXeL6as)mq zr+zHOX#BX6<*1-=cY6zAbt^LhM=1r0X3ZV?L$3r7>aT8h|Avn7=g*}4FuH+tT5$Dv zT5ej#43puuoMl}NB@^{)Y03H0NEg^y^{{KKpf<5#V`1^?@6PVZ(LQmFUvY!04lA@A=+T+<&^k18Y8x;iJbjWYVxst(an$wV& zxPP*zk&A$c;7M8MJVK(!LR+zWzdig$?RcSqqdhC;@60-gLnQ_PW1IL6eEp%xg;iB> z_o&8;@}0hJ1MIithS>{B(UjzLS|z}HO7 z&dM3cIXRu5w0bM3UC}E``Hx%iZf|chOio$PG!)c_{CQS_=TOSn|~-Zx0LYAH5Un#l^Jp5sjbr zC$k~15dX-4956m9Tyq`R)+S~yAH5@LKU%in*bam5-Ydf@Hv-SzA+EpDHIzk{T*+|n zyxSG`JZ)_Wy*^q2yNpp>Iz-YB`#&{>S423rTsWJ%4DXES>y&RU1coAg2U-Gav*wm# zX>a0C_bJ<9cXJ%=FrCghxZIJiZSP|eGuu62Pgr7ig6!DlbOJi1A&>!YQc z)lkX{ztQ@wUBZ_8b!20|`;qNLBhc{=-S3@QIVH4*HoPhRI%T(ymF-8Une^f?sJ@%q zh>M!2>>q`j6iHAW(XqS+7ZuIB&P_*W%zg24@j|4obG}M>?EwBESUn~VWfY|qrR8Da zVM#Tb5#XJ_ECLi2q?4l%U1hBV5^|y@j!0VaLJm)6?-sM`=gPX=!Q8WoZVHa`sND+S+{O zE>Cx-;()NF#UlmM{Pqhk$?#lv?HgkKaB;~WXDl-(zqa|q0?ZjyIWf%rv{(uH<@)L9 zdKl>G#+5Y95dQjQBVcMhJ_sM)S?yKDPqV9L2zz}C4MmQ8HXEu_uJ66XCV(D13bu@H zNSxaWi`xqZ>ubFCI!yOiq!bT{P;0+^xTp00NzuOXT`4icpW>0ciU{VoJ@lvaGW#?F zy;2!r;}E&jIxUR8-r4<5lqtcni(St!lwiM<>A7Pajtd}r!HI^8laq>|b-+H-#oQJL zaIEvz=w8uH4g=um`*&KdoAwRKQl2If9igXfwvW<(#io#^5{aCpnRQ&WY6cIuJEBG@ z(CG;}u2TBL?0t)>tDS&Y2=piD17)lfzJkHt4TXE08;vxTD9XWjLB zd@0_SD;_ZDE@gOU%A9Akf2s~brkeu-{|zZeb8mmBKc43`(8A6kciVMT!o z?YtXn-r#Yb$NkCRs6W5+wy`nD6NImXm{(XBDpjG*xJ7{$9A$cOfv%{e1XQ*^7q6Me zPyGy@`i42;{N!L?DZDS3-jYCWuB1u+c&0rUl%T@n*Hvpi6e~jE1a9{+!RR}eNK!T< z&2E8=*v(mxh+ma$cYS8+^l@&nNJ0Rj1$vl}H}cum4nOzTd5||htD45!=rQtp64G0% zanmweE(kUv;LziB>IzCU_U{I*GI5F)!*KQUAKL*F}9Z)fBH;h1+H z=bjb3M{_8tC%k#A$EEP<6@5ml3H)jHuQKIH@jc4XTs0AEn~TAM&i|PWI)Z8F$Wpyc zo!&{kI%ArEM^rkNfuMgtxpe8A+srB^5*j8<9QFjhx|LPpsPd=G+J9R(@PYgAT-;un znj+7kc^zq0Ho_tbQd*U1ROt`B+r6O_=~%hCxX82S{K)A3>5tAny+jEj|5fUR+pl(Z zeev@$pfe!Ay#Gv~Qh>jjk>>XYApKPyR-fJEu~epY#|`Vrm5ZvSSgUtoAeQULR$EII~EMJiS{1zEoK0?ek{>tS2`(({xI%x$7xUq)(;wA=r z4Dj{2WoBwUf4=qEiolw}*JuVfxZlfqq^kTgWO;KXJ0f*I%M1Hv9 z4pWKB+YBAY4>6HiB?~Pvjxm?F z|J)WLp@1u;s-aO0q%%U-4h-fF6AyPS0Wm~V^a$CP0|P_9$)h7&Jt>v^#h)bJ>1arH zH~{FD*LI{pBp_=3(_({3OrV!Q;&b>Fj7lU@eVyf(7_i4%(+)ps_ZGbb>)SHv@} z@CH)&CD*8M>d@L14}BqC;WE26_kd9cWr0-ltt(#DgvOUW6ch1hQa@+yUK1M|XP$5N z;q*t6zUJqo$^uFX==i817$|KX9(+&*1Adj+t)9b)i*HbIv`rGvEG{Z`h7&jK(t8Pqa*c67ldr}4mm||4(&)t#QC?K?vEtoS9&BAVO*LdL?NYb z>Zf3+!46WfuSv;+d0B>nYPT45zN*U0v#Sm534M)%tDr#HZzn(GPpdc@N#;|cI~WwV zK?pGET=p9FzgvJ}`&NS;xV>E|)9PZl%q9 zEdUW0q?LmV_Zi;QWEvrs)JYg27KeJp&mko0lLC4K)Lk?;dtxGM!WL3X_NGz7nt*&I z;_l4V74Pdd`621PVYSx`jtQsJm4wx*Urh7%=xYUWMs}vbU!z@O#G_f|<&ho_{lcuFo#f^=(PoWe##jO4W9PQVO7mjVQ!_}K)a2>xps?brqd5m2x-c}e3q^|YPAS7t2nWoO>cl7kFnN;{h!YkT9j z`u7u-nWC<1zJTXPLToi+d)cVq$Og{M?d(WfG|;Y`@5uy zmPXOIXXh4eLZ??t3eugi$+N|qT&b2DEzlh=JD#c>y8FCO8sD<^v5O(TDen3(3aDieD;q_C0m|?96e0>5u91$K-x*nrc&GzUsUg2-YEW(rDbJnLp@_f0v)!KdT z()YzBw3v~7djr_!z>aZ~z1(BmoE30R-TqWg{#=S7trLB8H2*TJ*Jr{H*b>cd_t#A| zmbgTMBy}Mj9oE*?zQ18ll@D6_FPaQKirU75PI?L3VFxVoO92RQ9m$23(^Ay#`$tRx z>a1o0#WlMIeFH?~OGE`f%7yE3$;~-cb&lu7`ro-thc7heIHi)AyT%W7&o?s0{~V!c z+WU&>oW(PT8C0w3^!vBu)XE=r#tx|?UF-FIrhn^Y=Y314PNjaRgkV*V!-Hcg6W+Hv z*rR-w_yp{o=)-)j*WKOnN-XVUh9{)@(4lQm7nzk!0)+A zpH?IqKRyayf-N}=6rf#4?|JN=ceUPMXH&0td>_8`V9_uoO?oPvu~Ew_bZIa*H#73T zvj{eD>St?cJ0E>L%K(EMasYG{WmQEQAQbgLg!75;r5I>mPBrH)u=uEz^2}>T71WbO zbk0x2P8=*6rH#nN734IdBG`BLJq64>Z4!tIEMrjI;DzGj+ zfm~AkCNqHa>Zlqy{EZUJ3oZ(4$**k*9I5E~@V-e1ehz}PvWE4v+@!y8v1LJ6C;okS zc)6~n8uB;BXJ zroB{Q+qp`CcJQZFkx7q_BWZ5}kx#QXzQ3F*6C%P!BNaV~FE4)vO%4t12UoTeza+C0 zBf`gb8^PB-%Ycn|tWAg$1GvMe;#dW>3{}vJ+Jn8FofG!hfi36Y(9pudhUn<%%*;yv z&&KL8{t5~v?>s-r$auOc+iAJFzI&%&LM1A>K|S|oB^6_lM%#ryAmI16fN>MfB;+pB zVkUx0b??EYVOxOHR3DY1rX9?~!N~d5qeVL`3h(hGQr9RfJiLyg{TzBoOG{JUzkeUj z(F$b-G7igLlSGX@9P@Pz<(gbA=KS@EsqVKMC+yCbG@RH5T-Yb#o>ke|6Rr)8yW!3| zzfvp?$D1)pJqua8k~>KiioQ<*1`_rZz9#F@2@}2WZ`rKPc>?GQI6x|>2x`^&ibf52 zPM(VkzQ-m)sUSjO+X){{ow_Nf0@P<}%Y#v;*9ug!2#9oI3F6opf?nS{I*7S^Zku+~ zy1n;mD%-g0*nv%1*tN$?14;kfmSv7%R?PrR-PympR=C0IP-kjoB3J(Y{w}VqAclor zEzRU`@4)R41taFRQE>Gfmq87iNZn)ExiphQ?JWURAUrG=4o>xJ0`hY^FYLE+*;t&s z*v(#mZ0v+60`@C8Gd3z7yCWI0(0%|TwI>(;bY|ja&lV66lN8hOI>T7KsE4}?etUo4 z>D18UoI=?7Xuh3A2OxcZq6f**;^hcoLonTk7!lUtoSGyW9UkST?6cM9W$h007kCPl z=w9l}>y{g-m{56odoS5|v{|l+At2hE94&12M{SBi)(bOdXXgH>js#RNGC%wx3u~{| zrWz~&Ojs|Vs-o@b_quce_-}4~PVcGw0+@829@lXb1Vj6vd=%mjcD%aNo}@4cndc6mwb4nv>y=Kv_# z61wUId+h6z)5kJs0$nj=`d4VyC1Wvbm)wXyd0sWw0rmxr&(H|RM#<*bBa%A`VUt6S zKz}4A#3R_**-buCO<&g5JH+MXP{|aYnVnG4ug3@o2{O&5o(%*t!6~ch714Mx{&)|c zrKt40Ohb>W88a@m*qD&e*C${Bme;8r9XKe@)I=^APn+WbdY6azL(Gi^^N)iSEO9FS zmdn+98@HSGH~>=-H`fIrjC+I9Ul{Hq$DluwR~k(f%W!(QuQgEC@>?d1(9rpMH9K8? z?=}%lh>a*KxuuR;fq;9Bv&=g&OlxbA#YWEB|#I^mNm6smHP|M`G>eU zfPaD>_azwP1`AY@rJ!p-4}9A>xja`oi+UXZKRo-zA0Tm#ZLo|Rb!`hwG)EEESFSPxrdz@1@1#*b!{J zWP2+_!HKYkDIN|H{{_A|AdgLxFm7C5w3U{Y^2BO=ShTIBu;O~Bsi)YiWncjG+_O&i zuI9Cnph(@=I0X?e(z4e?Lkhwpe*Fpz#S-96GXmlLrUY^#BoYOHP3YOcd(>KAU;oh6 zm6JmQt^TI{s%4i<%z3NO*e^0NEGjGv74-#u;CRmSOL5r2aLIwqp|r8jXS z8Pv56dtV#r=@1a(yg4eEu(G+UzneI`;zeCbfh;lXjXnBfiTm6HV(C$q6qi5#j#GO& z4M6^dOxx8B_Um#`18U^ZakOlOFA^xe68%KWJyueJCeLiPvF(oQKr4$a4-YLZUzQ&X zF3ASM3!;jW_6E+*_67!U08Uwc-gk;OW4{Fb9%MA$ym|Bfy_0FIo7Nu5NIoyIoV?sb zQVPG%?R#2zcc-74`FU3{lam)ezX5%vi;Hj0P^q=qr?dcg_;piAzBi+GBQEEO9AbV=az)F4W7ve zra<544+3^P_O;uB4W8O#)lc6`*22rn%i7D!!UDhh4D#*l;sOH;GuN1_wVS_WHuhnF(T5LwLAGc`WtU1`~*&h^fwn!saEmU$7k#}6m0G(Z60@7Hn%07nW~3zzfFt5;?CeB7?Tu&C%J1b*yRUH2(S7i= zVfN*Rra>00M$XdE*{SO74O$gxVqXGS7cKssILI3}poZLY1y_bc;uY8A>#t4Xi zNF0mJi}}Os)S}#88+miaeqoS-?&wFLu_cdCdUiF{uHfq87n`ufBfOx|M=n8*5=nI`ER1p$GEmexEc7oeufM-oYne%;wgkd}WjrR3)) z>7rDlS*%&2RbF1sdjs+Ud4fQm-d_DZJ#-B8^b8EI`WRx`rqsj<6Q%rJ+V0Q$RZ<09 zN*MzKzwG|X@coli@>c_$+f_!9J0qD7LD-d>*&ele_J*z8BIh6k#ApAGqL5iI3O=d2 zDzn1xk!zFsUaqhSek2C^lmc#l@U#vtWw0WhJ1tymNCgD(#eYA^wd9q8uh01`mNms? zCP0aRi2UD!ZcZEO1WRdS6QaNYaxg9?T}$2H_Dj?Rk39`F0yPBQHJ|d+ztb?kiR3DA z%rb}}&qJ&Uo2ap0(EX#He2OqClxs)b!xc`~ISOIQMMV8?aC4KzASrl2%n;LOGK|<} z{fhxXH}3zWj8J{`a@~RsLeRa}bK;DEh=%`{jPWTVLj!dM+3#)g#l<27LepfB6Z2;C zWtWI34jU42dXyw?7kp^Le`8=Qg?{>NEQj>`4hi}0>HNOFvQ2MgGKYq9e$P4>8yy9i zTp>wHsp@xlI5=6hmLeh_Oiken&S^tkC?4C@V0YdO@Vf=(R>9+`9iO}6 zHU5bB38UNUxm00Nrq_iGb*rl4H%`i5zZe~Cr%o1pFCOyWh6P@q4$n8P$H*ncz- z`X?(yxQ9P}HO1i8`aj-0W)=K6{#8BKy0WZjiWH-EnIU$*?()M&9aLb?hOpryi$GhCI-{r_b%v8ZR~t=CwfjDAh|^>=&H>srjp9<=pi#d+z>za4J8Ujhz-f zeXy<$zgGNQZx50Hf9q_JK+hl-W5mc_0llvz2l_$I$y*o0yK=qHaI3mFkIg)Q^*8x% z{ozD&anP*y%f_JCPZv`KhG5ld+nP@+G{=Hxgi`f#tHHSP1v*wON>K9q-k@?}xmcsG z-_Y7Nqb@1$+SRPfFkf7~>OeN!Plx|+DJ8~=f^)Dp|zDN>LMRVb@X)&-|b4DESZ zRh174Ej@)+9E3 zS;E;}^LNh>dC%)%RG5;jSj^5=ffF8#dTyHM;p{x*D(vg(S|zig+VRaOx&opK|DR=d zj7u;crS`hv<6qg%9VIIC?56Py`iRixel;p|)y*9q{ix){^rnyyJA+S{OT7I%Qdi#F zKn68DGSQSd^);HfH#!ybQhq&GEU(GuWItNel4sa@~ z@`JfK`S_Z{`)zm^yS^}kXf56qE+^SXm<|@25&2$BPLgz;5X3o8^zx`?T8_gmh7*E% z&IaQ`1q4`B;#8ZS2fY~-=^gC)@@-?o=7%CK4o-|PYx7bcDY-2U4vM{rt1!+^xG?;G zRtcB;_C}lP7e7+FM()dB$d{MQwR(o1swx}QbeAGOSpGP;f)*92;&NNp=4#7(csMEB zeYLKlO>$ceE~w_bjirmT8`$8C)Vq>^>3tKU#@Lk2&DoVFPrZf`r? z_!5FLzs1gKDY|)irN3j12*-NWA>R+U4$%cv0w}@%GLBl*VBe}-j7a5?5n$fDwQrdf z)GTR<9V15#K7OpevL@a?GLe#MEcTH{2U%Fu@pcv@X&+!9A&$iL*yz}}X-mJ&)ueZc zjzHtaq!9gne=K0zAFbB*Fyyt2QjSY8pXp{|JiF{=Zs!YPP$SUup{M8V*U{lGTH?(% zed10IYGfAe{z_-`4C#M7kHnbOectx7@*O7uJPOUrb5`@o8gqE+v1L_qAo4xlrrTO z5@5SSnf3ISp6J})7+#{{#x?7K_vxAJ(;?SJd{7!<}dRTvF4 z9%{Mo2K!;F&WBCiQ)YV_hG2)W6G&Rx&jcNO%%X`2No(usHorhYbz<;jF6w%R^uNQJ z$ea<1tOtQixvQ%bT-DUU&wsQ({oHOQ9d#(OU2VGbG^GZs0jX(`%%rx!7hDFkJvX0S z)MN__zS?KaoprzqBJ?1Jk~{yPiKMcwInOPH^z30lKokhdv2wg z(h-*m{`YR-Qq-*%C3njc6Xq|&Gw=8p-XY9b)tqSRO)-1thN0@ zrfiPK(2J-bS3&vTKCkj^*fR9v%T0XUQSa0AH5ZrRjN3KB*w_FijwHEfUxtQ2;mK*U zKbu-5UhCpC)d#%+=pUt}q^YUe+{tv@P)LeIWI=j?x>?Xc{9$k<5ZaPx5Rc>ay8aU6X9!j2~vP1lGI@7UzpT{hH5HePli| zss0?ZGl|=e{!FSfzW8W*9NG-56jOQdl+OF6k}$f?OS zqX*a2%s+O@xWX{qMqm#$JnZbQ)z!tK-JH+8>?A9zvxDi`#r^p$_FaWR<@S~sNP$ea zvjVl_haFUu;e{hZA8yF`jO`imYJLdE2I}vPkI`{7q*dW}MHLS5_vu!@gKQJB@E*8V zr!q2g-_GifsVHCw`0Bh2Thi7YgB_I4^!92yw%#qN2&9|0O<7wd!D4Bf+F@<7W1LeT zBl2i>rp%CO>ru0bfJp$%pg)<7U`OMy)f$BQh`7nXdBMVk6-kz6;_aA`uJM|d?vl^Z zyQi`RyDBn%5qidX2Jy;=HgB;rNQ|NL!clDQ#^d9>^*5n~d=}lK0&7-kCK@It&D;qB z{OV!Cw{LS*#)v;2g-YU*I6a$_=b{=TXv z~AA`y~ze_g?UmiBoste^6)=y2E8 zMT^^;o91GuQ*Xv;Qlq1h*$R@IC&6H_N_tAd_m_N*PmL~PnO$ruc|vSd9i?^JYr0U;npPL;zE zE5ww)OuX^a#50F9F`u2I8lCd_w;D$BNIW52joG_FE)xH|K6bh3)Bj)r-urF`ZHs2E z*yoV=iEvTNq3!Rx#x2=x6;1UOcRlA{il-LrWm|pm1eQIo8`jEvVXflQ(mobw$%@5M zk(fgFXg=6BYub$r@AZ&h5gP{wAxZNwx3{0F8#kBs zPEOocuEO1q1fLFuhpn?Ub7%L6{=~jv_GH-za#qz0R*@=k+vVF2UHpxX3)PQ}3Up+z z*^@Co3X{{m^v3>#fKccBDKQnm_u+m=Xz=aw&pBYCK^Q*C@$#`W*a<} zchfdkuNs@op9(GPjp;1QYoz1eCde)tgCkpmwDc0XPzq;2L<_gzUJNeZYJ#Uv^`;y8NXFa zO9#Mt?0KRvC=*DC0}4dpd<#AfL%(UmT)k817=hyWb5-h4(J~H7wg*vx;TC~JhY%I^ z3q=5^_?{l8ucM)(rKN0gwp4F^PwLoiZaIMbxh?-|*p(1K32s!^Uz=gKy(l6=fHPP^ zMFr~KZ>ohJE;rP_$*@t7^HVF@S6#tBOG?Vh%4%DVfKGJ2015^_seq#&z?m75@9OIM zj*Bb(C9t%lbc%A!bkE`_iA%r1UY?4~^#z%TRV>nJ`|e42f{}@sSvnDXmiSkEidE}ex~GSlq%HSn zQqtbu*Wk)oyA>;vSXsGz(Z_Feg7I(|_mf`I= zX29u40soojivTuSkD1p8*Ey`RHE_+xCHg}r6+`<@LQ0&^*GII{F3S_cXD;Qn*~UQ)ZsQL6d(Lb%4e*?sASJBl)qCN1nqGO#`X6 zlmItMovj;CA`TBQ>Xb}tdYpfe#qEnw0UB`;hWqFI4Y-}8A}jCawtX~4fj^u;{Y<+j z9t=#TiYOxd@Bn`oz^(yH;sMr9|9$aQqlj?*Qa|&?hCRhk?@&!RhCglhiQlOlHk?Fa z_mK8thVQ{okPt4@>}-!^&{E3Pq1RZRY!}e-!-cyi_(QCSDYV$KYHgx9CB`3iGF`mfjOmvRID|Uh|nvNmMyid_a^M0}F0+DTUE0 z!XAP(s_mJ(I2z;qtPp-H6V*#GQu#{K$9+nj0)U(fy(UXYQl9mcyxoFxyv_^p``dQQ z46y3a>^b*L)?DTL8pqSsUmb9cFo{#d`_SJ%V-9gt0BP&-&j?I~lU*bfa1u`)CBuZViDOMzN} z9{8$6(Tt6N-J&@T2~toYN@;o)g}p#Z?-Go;4;xV!ZZ>s#-9FR96ACUj4PY?|2s z{Li%%p0lqr>jn5G!$p`w1dBjZ&0Uq^)O~V<6S@X!aA_FekgC&qqLCry>^XEoRU!eg zWYRav-yYU-GwW*Ww|5c>^W-LQBIWM42}>1~Y)%?&t!19w&nhca{i!cuH<^ZWM(ql8 z1h43VeD9AJ`$A^#DI0IcJJZNtN6pPvEeVP~ZOxF>)#o)HoUI#x1O(?BN`9{P&q>ob z0X!W5cq_F|*diimc6;_aiwb1kS8H91F+V%||FHEIZc)9_ z+pl7wNJ~pAC@E6X0@5WdIZ8Lu45`vm(o%zTgX9ncqoi~U3_WxXFu)8o)Vsg$dw%C! z?{%F&VfO6(JZs(SzCX(*@H)c_O-AUiSUq)P)v&%nW}vT&nC(Xt&Ms7rTvOh=C5151 zv6FfczZ0ZHN92N%#>qV`aC9EJS-G<>QYl2SyGuG8@o6G(GPymsy}77Jo5Mr2P*#;M z?F{cXIVD;?JJCbhJ1X=u#mF7)yyyvR5}lbwk0}*D%ga*Q z^fXY_R?5CwER#$O!F{m>0f6GHHjSEi8FpwF4GJ}@06nKTIgr!vKgBrOZEd|!xUo)`u#MXWW`h4Y+#1{`%Q9VI3132j z&bF0SRDHPzyUxtnZ-4oU$Vlpw;EUf5Qc6cT4!_ZEJM^Svrz2o}^BnNQoQx4f4Dxt? z($vu@ep@nTmzkN2Qv?(S+3H1D`a*Z$&V-P~e29=#+vlOOK%re;6A_R3v*fr<6@Ip! zkKpTZ`}s&{{7PQ5?PqA+>zR0p95NTxkUirCCFUz#Q`6(o&b07wJR;%0W-CAny^}F8 z;IO*V87Ux8Wm;nkl>L@H&xRp_;*vZpqR@bIQnY>FVY+SgTmyFP{}B??|1Tkt!26YV zq3AE}HeFekH7GDWMeG6b1wa36^Ds%jiIP8GzGN6fd=d<+j64v;qJ(z?vBT-n({-+( zAX!$o=RgYK))#C|Lw5;V&+^OsxN)ds-6FUF@gR2pl=RW)otP?(HPnM@k1hXSGd(4W zjxHOkz43Ly;ZZ8&`}-~_R9^K+C5+3o@8^YrO;otika2 z;%jZKYBQ%$EDxw%TBbVSP>ix!NO=+|G%oal?9t^NadS-I)no=`FLslk7P>ayH%~~M zmBc~1DxLFEHv;hiJHT&3LD9*n#iSYi!`%E4!O57wmMX@Rj1W9Jg`V@?75eYBkL!N8 znH6{<+Qays2XN2r$@qTmXmY!|d$P(8C!+~@OSqJktRVUIJv{|i;_-!JT4D}dH!UqA z{+~AM-D0bGy`ET{v5K3QNv6n)e{*cA&WqkL<|A2R-_6s~rpC~}KIS}Nu5H$@{!c-! zq>2txu}K&)4X+9MnKeJwktV&lEZ@ApzXHewD~cHzDWJf<;Y;JD&je7FZ6vRf9eqqJ zv2dYm+~2n$*1o+6dc^L(1h@R&XB40BR<`GHx77iET@7H3sWcc^NbR!{EYhfZ^Xaf7 z46xc7H`ovps=7i~01vjB`DKFM0oMoyEnuf{N|X;l{{R!N)s-OoI=SnW`k;u_S~M?xDL z`>ZH*4LhY;5!+tAzDrS*_lS>&z~FU2@QYGONHY`RkVP=w6q( zn*7R2fJLx}TOHvuWO_P5>y=r{06+|qY_IQcV$zdW_9GT1e}y!*84K2Ljs?aNMKuLdJB9kE?ku&t;41H@?T2|BpvuXHv z9e_em59f?95Zc?j?ctBrMkL}NKLiGjz+o)?dF!LDWt~o;UYVYThLbBZKfhL(sVu*A zI|@iVrAt{V0%P=iE#P#n@w)I{QTN`RZw}n7`j$t4Pbr(esWH?kod~%yUT1Pw@YI{Y z+3VV>XKLQh{Z18Z&wy@~=3MC*c;^lYBO`urYEW@m9CQQZ;LTrle%s_HF(0*+GBn24 zX2vnOh=bM5%yJzakRlV!7x;k=_JU93IAe0ERS6f0ZChR=y8dsb)}PExj9y-CjUNyi zDSucRY;GKOl??dZz+$LxT2l4G(bZw1p>fnPL2h|tkNfbL(jVMkUsEQhvx=lWIaH8gf3SU z3v~VFj9o%=6B8aW3HSJS8EwS(gKvnvj2@B`D#9)`X|$?J%SOw=A_0k~9xN3fjKbH*}|*9$6{z`#&|~cWJ3t@5!pi zX6$<7opm;ls;Us?!Ae}lMl^+pc`d`aW_oIBYR^rvnbWQ|IXzN;e6>IGW#0?ZlTe5N zaCQR+Kz;pi#nrFs>XGTOe6UugpySCLCirbR3dk^3n@LRH`2=)nnBc2sFdF%-L(YVA zl*s4e&tG`}{h(m#GzfU#_$6-wm~-|Qk8?}?u^YVQ$jTa&VIAOM-oL;=kN?aF+L||c zzT9?fJ>53~Y*moJi%ExuW9{G|!E;f)&q&JI_!yb$HM8X_ux-pq#?{^Pk?=Wz4e-B0JBWaNBB zipOo~Kuv$VB2z3aph3B2 z3kjK0Q!mT7ss5WGxiryk^)Ok_2xb82R#LO5d3AmV%M9L|Q_>7#xe;mp%e;XW^BaSP zApK$g>ZJXci`@r!F_Xu&+6SAMa?wh`gDW*vVVB(x3>Z&fM(5p4{$9@VO`v-=vr ztu{Ayr~jrd0noQr-?C1n_*wDfkYZk$j&d2c7fH4C57&MfI7~n1xk6}p!O8t_7sottvQadD3aXx~ zOPc-~{v-d!$95L$>dR3?_F%gqV}-e$4|JFY4lE_AA2zz|G%SSkks8%M+uvv%B%O3w z)WL6#n8}zfZP;Y{+s{;Wt{qk zRsHvcSKeqiYaD7V0?EYf(R{mCRjAsp=7SGf_n!Z2&5wD@lZw4^L>m9gVX9hBLqyNqy4a_?pr1eCLsmA=#NC%OHfr!Haq&5It@uJTUPnZD zu~f)z_(@Iuv6lLGW%V70aPmqr=@U4&yZgYSBks>skb2%(09Ckf&E;W7#Uk zjB^aiv4_Wmk8#4p9M2T=^hBOYq{_JK6??xf%9eXhA>vS*P44vTu;KR?XNVI-@NdvA zy&TY1za|XLwz9UR`7bD*fgi&fzjEbsWhpSIGWY0ph9>^qI7ndElDu%D1nnH0Vzq$j zAoiH}0IOT3M;sg5(9nRqtyn%;^`YUrIaAQmH8w5FF{q@m>LlDG3+;b&Ilx~n%uSdH zB%FO&u1F)};{Jh8uo?d1P=zP;n%&3!SKT)?x3J{{dR1VSXrObGHBpsC5{+q))UmM$X!$f=B6G5U;Q@#l0 z#^gi6tzQT7Eno=622s&Q8(ZTvV*8i^uiqy{idgY*9=37+2|ImRbDErHp82Eh2SW^E z7`SYU)mRkY=E9WSryr;4DxXVugkpk@gt*Hd5$Qcw!0E0_A=n!)_Py&j(yvYi%2wD= z`4(XjY$6bI+Su?^QIk4B9s;h|u=AX@#m{!Z{he9|n)l<{@rpO4b;;*qmFLZ6z%A{T z;6-OdkKC3}MCZ}*ODQZ8uy{L4!r(5a-ZyaQU{EzosSasz*45;Y> zk7!A6DU*}u4}i;U@Nj%G)}=bydqGJje`dg^kQE$t`a7Xs1eKAE%1E-ly6Dq>XHqmO z4+Tx-i;|1u>BdaA$SKQhe>RR&L<<^Y-6KPEn7}RG_}yJmQ0+BUA^U9jeIF2r;G-fS z*Srfn7#EO0G+~PaC2AwP@_@P!wP#{iQuohm<8||Tm`b+A+Qe zZaeHS!UWl5=}xqDxXSQfT`5J&it=NAI=Y_Zh$8|X>LQ{ng?^QNC*swtnrYEyqGAB| zrOcao3$I>wcYXM%yqc4X@nI(CHX9 zqHp(1&x~Jp+-gkB%P^bk54wrkHYHE=F{~^5aA=4s#vC$zZ$Byewe4YdR!{3H4z4&T zkLN`e2m{yDTCPO63^qgP#q<#(Rk<1TaG&qWVAM6TTTEyX5_Fv(@fzeGx1qkmlA(6h zqeUILhmY6vz#k48LiU8nCnDQr$pVKo%u6LHi|y5U&-aw@jCIFZ-}f^H%D)qxF?HYU z->($(CuWf4pEtwzbd5#q>lqrif7A2S%oP$nsVE4*uRgjfZOQTI9UEHaDIRs(&Gb() z9d7TIad*SzTe*Z*Qt!Z9YJNceX?HI`+c-L3SUi1jp&n~1vn)cOp}PNs&|cwiS&8og zvuLIix5o752NeyCxFqk!ESkp6N?)X)!I4$@2u zC$A5#reUF^{(JjwrPO&I9 zxG}T#6?|=KT|3Q8zNOQ|D!OTD{NF%zjrqw&wut*gCNpEp9z9y_{n#n!u6nwQfPfYS znKD0d#HmTJsga#hTXaudOd7;xO`qfY*oSVUCyW)9P@ME208_1(Z!~Wl07fcP4Nq_v`Tqy=-o%TskaZ6me0qw1=i+);NVQJRz3srU^X~&NGVR*xAi#gb^9s@aWLN6f`J!mOzMC}+qF$BOf5^1 zhHUcGlZ5U{uzZuYlO~aWJm(=dTFz)~*I)Kx6NM%R&>D?ymPtdR^n3Cx-e!Pb#Z_bH z)xdaOTG~o~`No_dOW@r4iNpw~&0(>|608)zrq~-=Q1EWi9ZjJNe-|us0-yLl19@uN z&0ieP6@pIpTJrMBh|whNXGgJZi=MWG2!Y2f;}WcQI}j&~(~F~{b}jzHc?t{CR?FZ1 zi|ZQKxh#Bq7%D_>v`Lwue7Q5g4_H+_FFuEv9?t7(jRn6IbwN3~ZS{hJ=>+i=DwLcQ zwsQH^06jpa;>LgJwm*}!_4pdVOT0*J-Jh@%*Qm|I5J;ZSd*lS;#(tGN=03OSeONfY z8o$uH_eXYYM``+;20`%cTt2{-v{|)230os831SSxaiGO`T+#U z_nQxv7H>U1DBnUEJQtWTJBx42Mh0^cVS%v&{~;uz)tALJC59+eNG!8hHomEAn!y#z zB-V4Oo)N9XKIFrOE5G;`kiJ|k8}qG>Bt2tr`G;}t-hR0P57 zLPKgbb(h{Z^|8^&`I>^-{{8mryv9c{9I#kE$riO-n6OTnFehTnI<(6{^;NHH;vET_ zujo<19rMPlrj>p$G4T-ZkaNA}^mv|5*+fCG)n@XJr&QLo3gtqg2d9Z=p{R;-Ow-N| z_~O)h5-EZpuzYv7^-}uPXy$!6Qqq~F3`Xw_z>JcR`Rz-REQ_S`9v&e+m5WVoy_y4NI&$)I5WmadE|M_Fbz-u-mbeY zE{w(Z!hRCumX@2Zw@@Klk3t7Q^Ip?ixG{NgYKQ)O3tz2YBf*zZ55UDY&rw%DuJ)uT zFz4_PsHwexY>mTJ*{^0Id#W*He)2s@%#(i|?8dc%3C33-zfR-STKmAfTpAzxLnRT z8;uY3rcpCg((pBISRJ#FHKu%Mb7dpDJ0g*J>%%A7hF?BlB_8T~_wgB+qj9aK^E@-m z2ZSZy6&{a1|2H|smnGnloC8n0Rd~!-BhlZK&)tUjJ4ef?$2Hl=SS+faDdPhHs1SDf z+N@WxAn2z%Zhkod*Uet8Q2+{rXjmfWjT~EopK$1VUStA?F(xRD$m6T7yZRvn)a zPnnhfkT?mNoS&()9a;j*ULN}_dbLHA3EJ^)?Fix+WWa+;i{1dPW52!q5Q=g{+by8X zV5@r?vAx$ACbZB`&l}5=-8XWe2iRfXiHUTse=sn=D@UC|pI zjPF6pPplWh7}|E`=CmY_UqPQ28SWg6xdx9PlSa4azE!J0u1fyxRPXMG4_u!w9F;y{ zs&{b;rhg_PLbT_d&yIqMY$^Gi)^YD(U6ebe=b`0OQn15JPa1YH*y3{PywggPLHVgh zK(A)~vBYoTNtI99c{4h$aK(Va+{ zLb#!m0(*T1y*G*u?9q;3C=y2}96lXO&CtvM)BlyYn1-zi#xhE__*U63vMGBs=Pazv({_DN({C&W*M zo=F79qEATWb~2p!p89xgxnB+fwTX%4YgzOgW4EUrq1=#wjlEdDnC%>H z`*Q1G3_TfQt-98B3c?+*rx`g3Jh~J231=b1>M8yI0ZAG!h_{#i{Hd`#gPIBa$0N~n z>fV_|CMj&gkE5xlFLvfTnm2it89{X}jC6KF2i4}Clw@#?I=l`C+x868j!@oKca{)k}<1$SI={+wz5)Za=fEX@ua3xc@~0E$*?a&uz3O zMQC990Hy2v)tzxrKq9wyUkAHV+j)(Bh=yYjr9`TBUu^X*7%f)+gU!7F#zVXxDT*Xl z&^Ofspl3+Z+{wy`UdY~G66X3A1^L!v-$}eaY?Ip=ru&v%NZ`66i76m!I>LU{-lvaY zBdSJJu?MU5!RL!QY?Bos0`Yce&}K{O^-da^{SHQd7wD3Psoic3e1maKu2iIDC4QCktWt`4xsX&K(O`_W2Qh3I*KV(ioH{e4Y8Dh~lkWZgVt zmL6sOv2m2&8z{N9yV?J`_k)I8tH-+>uYRmOV8-}GK=^!#Hy`S0D3KmQ<`t6gqaMTY z%_lY%qP!r1QKV1iwZ#XYp9hShFH2g%14v)PSRKfrnX^EDeW~xF-ER*iI(h$AFG?DJ zCYSuNFf--}ZZIvz|8C_>oJMcSB0TdV`c+gh=F#oxKd&5e3HcdwHvtF7Sm@~XQgz0E z2$>G@A-Vq{$TU{oZE#stq$iEk75CkGuP;2(U+!l4^yiI@>@y0D%#I{7V`JlZ6R#SX zPJ@b-((lTJ;|c7HA8y%v!s}hfo_S;Dl(JGPr`jPZFT32>(oGANs^c4=o({yI`b!39 z%aCp{6uS~u)3LtLdxQja_@z{^@`3XnRyIcXM?DSdH)kYC@Wv#0HEe+QvX1Xl&Exy% z#@kZw+uSsW7S+I*1j}6`ZJX#A*E=fo7ISAOJ~hJD$w93vt{yzk?9S*L>KgdDE5S3u zb0S9ZN}%W|Lf(8Er&g*^FepOi+I6 z_eLs5`upj7OUu=h?AA!#*)~^xjIV>L*0-;HKseR6_klNEu5qQbdyMUZo<%@_EB+pU z6O4e&3XPlk`L!V0$wxCfbIf-G_8c`R{=m=g=1VS=Y3rMI%|)=R;IdzMe>HW0uBbuh~!Iv3O%dhg^gDz8jBmuXEGXL}} zWf-(r!DKU!Pic!!1&p&_A@pWt#tfpAA#yIL2uUfiD-QL;NG-OwRpLrDpMHV&(49+471Z~N0)5htDw(Y&u|KoWfKPhNZg zY0T5Vg3q<`$JRDB$%_kkmZ}Nd>e&4M7_dyFYm5S3v*HP3vF8J%8t7;F`}_-8hIPCbx+=(s}iUyxmUth$t6eZD%>t9ze` zY4*5NKkU*=;ex0GHbBTYYglwyt(o~X9*8hQkY?LexJR6GMW%QNdgQy=&nR2MNDR!@G2_mT2Sn*K0g zTdrLoe9dT`*YZ-Wk$T$BbJ@`~f={bGR&j69Fq}HnvC&mvA?57BJM$po+gsoN{+fDt z&v%LnRTgmxog182~f2cV4$2 zQ{=YELp9J+033~(rqa^VyxPRaKXy$Ss5(Yb_dAw8Uf+`ZO*O_>a+a8wJ&@|VJy;Z; zLD?rUE1>VXJY#9_BH{iBv>e+YJF*shEqkaXDaaMrtEn7x_7GJmtQXLn7LHmvk5=Ii z<*7eE0OpI#E!dWgn4~V*`b48TXLbzh&C9q-+YPWggNKoqL4yH`P~*;T<&DoKyr{Q! zZC`#MpMl|K-oW|mI0V)AKo`kP_lx=?hEs7}NYtx^A>H+bIhC<>9$ljPFFc8duql_# z_SenKZI@ikO_gU$n4z9`ZQZ5HHcwz*@rsb&Vc7*|0&$ ze02$)F+Qhg>i7%NMj7Rw>~ib2I%b~l$YK)m?-ZS!EIgn+!o?`Qct`VKON^dS!6ooO zoD!c^e)Gv>V&A11OEOOST}}0MU7b#1Vidy8ez|%_dH&*KVYT}3&9h2XDms=*-b?<` zg~B*z7ApC{3=7HN-)z`U)DiD1rotB{atKB?AoK% zg08FFueg94^LT9Zx!S7B)=qT_T-iEB<*5SJcKl8}lRwT}(Ry{$@s(cHdA0W@H9g~Y z*mSNR<<09=rcE}FMjO%=qXuJrDr(~#WF6r`4>UVoA?(NX2S=Zy!P_TH(zU$;O)0L( za<%%yowkPj(O#ocjm;B%;NK|R85>IBNXKQs#yP3(s`GcZX zf`m7R!w(4G`p~u+zvzg2J?in;@(0)N?h--w?MJza`7^c73}WmC0H+}9Atpe(qw&+0 z^V>Wto$Hta2ZrA<6M4JU)rMR?Ex@B_6cNEidVI{wxMxBo7v@HsGF0!8I&>1R!5O~} zJH>xx;IIC09PdT%i9KYz-Yo|@ptvF(N^a=x!FB!6BGD9M&eK@;GD&s=Gd_b zFA&3T`S*P>!|hV%e|SqMRcwp1H34fXNP_4_WamrV60ZjZXWODplfMK_UOq@N9B-GZu znucDv(PdHE=fr8{sWH$R(52v1!hXnDznpve;lm^}Bje0a=UDpIfRjF9_G_|6>RI2t zImAs5Rz&SrlfKE$j_P%g=Q4S{ry^Q8Bf~*MBc-hAM|3eD6*&glhEv81) ztSzIp6*k%QU6n(=DZ#Nuf1x?Rd1qSlxhtlqygp{2z zJtroH){TWdijv|Pi20h}xy#Tc_9Pk?53otPi=e-v5YMZv0FyI~LPN{UUFFRYcK=fJSP@TdOJE`oRN#=orc@m8nO`EHz`!on zcUdFa@N&oD`t9_^mFpWA!$%75Fxz6y7}(1=jpMi*UhD>U^oy#h7ps-gTq}O&9SxHr z@+hw%jp|KjGrOA!4HT%+g}G{tU+rWqVn6-oASIzk9r(^CzL8L z@1J^BGP!slcZgQ704AD>RSbkv6_|Re>lq@N#P(PI$j2sMKV#YhfjR{kF?K!lo!-(c za>j&+)zim@`^@VKe5CA^l66OO1rjHNu{i__`jbMYW92G$vx-e2Y@u>BY51f=|_VC<+@B64s8|D@NYD%RN{k4yD&{z4JL!(HYH7 z!D(5Zu}MCcl$M(n9wPbmNBiAVb#>OB$|qpvl+RNk-=}>uPD6D{mD#BJtc1uC+w<2< zdWyF4fo)uoMEpb$XcG>~+<$#t8I7QifBG*7+gR$XIl~QBqXICRLR*1-a0)&>z6wx%S%1! z>MMD4=5ukNtomGPJTZF=HSZ*~FX2t$Bd^<;;|+_5FR&&C>%|DY=o*?Uc18SZ%+>6L zmR7)MDus7tEkEzZ`T(NZ=AU$rsJ*DJNfQyF>^BZA&SMK-=8im6y|F;O}|3T|TxG6TnweU#{GI^VMsjB{pLXHL8Yx6hr{^ApZhHrXA1unw4d*GmW2Hjd?Zzf*d9$m z**9-7pUfVkhcJMDC?1z>BxpN}r>ScySbOIu?nb~Q!Xe~6fFF_9N}cZiAA0pa82DXGfY*>4%{jy>SO|bqSM?F$VIXW`Uar-=zy5g@5AbmSRr;((#Kc}qB>~7- zB>TYndLW@BrFCPal9G}!kAM7FB10X9_b84_c!|&lN6eg&kg}k>ERM|UH_GQ)5q_hB z0yF+rN=jZPVJrz1%%G@Q2X0Pib){*}0iBc!P9n$6q|(@NXuySg?`Q7Ea`BywvAMS; zj`q3C|NKnpDQw0Bv!SXz8%ZqJ@C?3#4Q{+1HC3JW^PBKaz~WY*l-%fN<_VQHu(Xvf=4ou-!jM7Ra;8k_i6WvKwb;GlNNxw+&NL)yn}v%b3JlLiJy zyS#Ku7z}1p>AteV>znVt+!->j=c?=LlYJz`N;fM}7Y{LhoC;brWFk-JdoNl$i3vX~)@N)}IbA&9;#VfYH-D4Ig0E5MKq@yF@3snDf%A zt@ZkPfsbm|5xPMUbaqr#q1$!xs)_pMOn9WOyX&5M_NNx^`mv2M1EwuX5eI2oi}sRx z_8;EO1KZ=6UTbVp&i#Z{qRp7qLLe{Cw@py;)L4b6SVi&$m)?#66eLIl`N6?qZy^8NOXSj0aqSWnAR)|820<8f*lk?Ef>)42F zA6DQ;Lq7fV9(#*UFz-;Wm3FU{6 z#s2^C`ywfoYtj?^^o0ig^{+89FTMTw;~&G=0Jk3lq2Oz9yE(pw!f#BwQs!V^Tp5hp z!a9ljl|0YfB#MWx-o{ShEf^f{?YSzA-BGsN~|c8zzn6z5I%gm+4jgjU{zUG#gmrjGP%@SO-- zXwQ7`pYf`9Y-s3dplMy|XUnT7{^W7d|7xcZNU;bK|96;~xiQ}UoCO&poC*4UXVDK@ zmS|R*@G}6PQ~T}m`)1C?b=W4{)!FR~q>2Br(!rI%x$Q4}7f+dwi0u0LdZ)mYkIZGZ zMFD8``(ZP6$~QlSEsHAUACNFrRPE_gRcTGD+8G%c70{3zHO&7?q8R@*s?dV98 zgmUD5xq=~@ihlHIb@@&&bK4Z`Yo`2@!+>DBVsl+EG2pj<(GctX2Io%3=x)%HIaK z=DTc-$K$){i02fUncLOIZ5fa2-7I^#&QQgR!CPe8VZbJd52XF;wQiO@KW+;L?&zWa zB9X|LLCAxTCfh!<6EC~^4yfh*kY9g`t-eFJ4RkYDXpNlB?bp#JhaaWEot*E^`Zwkk zb+l)Y^5eTjvLy}R!J^ybMsKv%ys;-z$5Gu_ml{jQv?u<0m1ciUm~fF=k07n1M|Wn< zjQ&|Zy^foiwU}TZbN6l$3Jne}-N3DOxmgEabx7=w)d3P9YCbGDLI-_bL(QD*dONqZ zTIPy~t?iG5W4-s($+%3gbf9McEA+$8TizDpT%mzcNW3>jy4hhJ0`PV83`Oa2_;7q` z6?0;ap)9-Uho5j$H$N3QclU`TEwd~5jTs%;)&WOq(6xCqb*!zY-+zRaicSgpui3L+ z;x(;^y>=3gDk?(s4jR}wneELHm$S4t31Za#e?gPWcfY>pO5rntP5WIg<;p(>A*}qL zJfS{136}9W6w_fC=;L`MwVh{oD4gn)B+dwPV`AAC_@l(_$~@Kt|W3Qam!GE_h@o_ua+6h=!OdMyUVFO0hiKLZA@+5bAB>E^YtsU+M}f_G34a`X zB7hCg4QSCHOy^f4aNl13<}(YlY=$bjujmF-H)0C(EqLKD(X zgv%d|SIGKqUd{Egk;%JBu4JkheinK>{GFV}Y+o>=c=Ef?>4}39o$f!qv=oZohQmsGT%?SY%&X7TTFeuTi565KwUDX?QIRa)BUaQxNFHBnPl&4y z+l|O8DPwp0{9c<}Ew`T;@k#jkT!eF4Wa{gXEnm^ppi9!{hG8`b+Za>tzKX5_LSrU%&A45BjgiqV zyi-L*dwwAXmp7D2f|*GQy8O^yb%pbv!JTmCT%xyGMfsoU=q*hR{ErsU>irZ*6*7Jw z4lzpKUD;fF=F~V5?z>Gx1Ea5KPK9q|j;25;Vba5!Q6P5vxe#M=5wIO=92)1?;jTuo+vr@=956?L_A zhaSRve;YFiOw*OJ)Ez%D-FQcmxCmt2bVq0?@p2npClz;_dQf7{6TiIGYA3PR8#}7@ zGx(g3qxTvk1wnj|WYMqLU1jB&Cb4_sp1iz8!*_Rf`XQ28t|vBsCJ;AgcZagLf%aay zIN)CJ@&Gm7S?qA)=Fnw&6)>PzR=(4NSywSRf9|*$ijS#bDzKI}N%EajRdce24KtT- zZQX&2GSwKN47;Cw_eb#D5w&Rw!Kf)5~30 z%<>x6V`y?F^o*$M?b8J?b*B%TBrQjxq^Grue*IcW%Yph{y!bDgTFTbDMJul9T2SCE zY@3;S@jbfSj!#2cpEV|mcx>BKhIjF-^AzZ+PiAHtS~M6)+pdtVh26pgxgReY^OZ<9 z#{=v}(EzjK`gn?0r7wZnHO$tAt~~CgZIxjqQC>Bdf{W&fx!6y)+_xXzfB2ah?DuVS zqR*_bL7Fu|89Y_#2JDMjc})D!wRL-=-4;g)AnnW8@>g;mc|^pT86Bi4PITx)x;{1q+DG@oU_gaF8i6Y3N2=* z_#D|zsRrMS`Hiwmmm+Y+xv$)3sF1~D)e~PUt*2M^bYZN$f&yaeHMYJIQopUM!o|^U z&8v$7jm69$>IQuQBBS2`RpM$xZ8CF;a?b8*=ieQu8+=l>ZWGJbvD>KAYcldAvb*sl zupz*g^`>)ufme2I!M&(*YK~DC5>TfoCNQ-)yJ!BNz^)Np1&b&Xt#Te#=UkelRYY*| za$a9rW|~)n>OaFWFSbu*0Y|v!o;q;r6x$m5zqYonDTGZok=2;=T3Wx3N)weJ7ho00 ziUx~^Ka3}|bNPh6VPy>hQepK$C3GmFU)8Rr7mSOBGUdS(fN;g90!i7$vi`jev* zCAfnh3h(ZcZHbC!%kIWOD2E5a2$pw*v&-Ik*q#Pn$U1u1%<#ySP3{0O{G_jnx`%>V zlAom9l5GlrJ$ZBI&+dmT1J>Q#1Yl3IC8-Qg(jlDeA<;5vsLbVMk|F}sD#LGHZ#RB- zAdY3Sp@CHT$j&6QjW=do@HHq9%Zok)w!ud8eNfFJy#WYq!MUwwm?%56BO)hQ60_>V z(*q3lRKUA4Rm==;0vn%2%COP^W1}%>4&_Dt#^zcOZWuB!JOsuZpKy3*I~-hg=SGj9 z12n5=`wxRn>#S|+-MdQ|w>&0&ec|V+OdJVm=GFpoaO#}89;3(fo-OmIi!9#n)6$sy zcct;m6{KT7ew=hgg_YE7h(UM_Up8;$~VJ`cy2)pe!7nN!KH|L!eS=UUd zmnWTn=c;qz5B*kU$XRW3w1_~D7eQ4@PNJ^f2Wg6yyMfW6qFUZ_=Rc%4V>myLgD|LS zB=)y>+)nd>9Y*vJ@7rc z_Wt^pwie9)UM6u2tcxtBc_oI2syvK}mpuD*d*^vHPf=+(BXBW)7jsw9l7OR{-U<_!we`(KFeZ>HWntu@ z=`~i%N9MB(CV94)IoOni_KZmb>Fbj?ft=cF#pfl0(y)AD=#Q`TDlRXU_NI`SIcv9P zfV`v292_^zdRX5;k&cm-aS_4IjnPpB6%o>a6jen&;2d8}7Wq_mqTnpj?R8Ct6B&8CN zO8U3WM@fBix$xDbdSf9c33(p@@t1!FZLVR!B&r#!;Ue9Bc@EZ0B1Q}@9vQ!gUMi2y zSPMSW<4HK5kBbch-`g(|k*Up;dDU$%m*Xyy!I#7$t${ROpk1~% zD=G}S&aJ^{*?uURC$KX6@U_-g=<*-P=p+9yxd+!E*wKzjXn!_Gj-;J|h0_F7OpOx$Gr+}*PG(c8LS$eX{{1JppQ`C9u3 z%Oyl__V$+#Km^Qz8|r!fKJQEt58j&3s@;T~hud_&)E%#C3;JZ4bd(f-v(P@Yk+Zy^ z5#i$`;l1_VTSKFFd}TJH=jceBp!lOM9p@&%mBZiBprK1oe?ZDCvUv6SkIct~aAKh! zUEN*NWuRjUUeR?LYG5hhSiFA4%8k}Gu%yRs<@^7^L2n=Pd9>A79a^}$R`)^z`YkRy zwPeJ+SI%mQ_@4^;)pxVTD%Sa9P>d2jRYoHqWl8_P1jbCy%vzjgYNt>uFB`0_Z9yK0 z*z5KnR@T4!`@8X@0rFR}k2v7eT9%{Z$HF0Ub z4hQ6}vx{>h$+D&bEA+`8z+o?z(L-$pET`MDv9*8_gMf0-WQgn9teH(rAmLi%?mgUL z7nMo(182)Xz)}qiq~~qd?2NS&dO2R?qL)tRx+8rwkLtCB@l;k${s~ckNU2$w0(#{VD!hY;_(_* zoNL(A&0pz_N>ZHfUeo34c$4R9_$~(i=G3~ckz)qrwUDbqTjL;JJYzn*W_H8IOf9Ku z9d~_!3#2nK5lZ*vc=qs}MYJhRPt%-^70_mI10*Eyl}ozd8u}vNJONV6uO|kzOkWUs8gQ$sXHD%JHtp` zqwBK9^_6;3A^elO`?Q(E__!wA%WKyTWeBQd3N+|wnBrei?1@pyOE^BO`L->1cJtU? z(X2fn(B5q*#c+t5r^L9<2IQ+w<}d@Mm*sHljTISBc$sxd~lEcto;D??** z18sA210Z$`Fw1*dWj6oHNAUkI;@-L|%CPVI1p`Dtlnw#u?id>B2I=nZuAxM_OL7>x zyOCCrp=;Lw;8BLBMFK2cKs zr20uIR4ov$k}Gfo9EAb9s>7KnfNo6@fiqj}^t6UCc`i~=&rpROv8Vf4dqv7}(D(`g zOV?+Qox?D6faS-un)^z z(a(?1&5f?a%~J2xrc|pW6Iw@iU*Dg%sAcO_mr(q@KHcb7&gRQZ%AZ+ufZPr4ke<^C z3kjLo-f%NArj_*Wmd(%WtOAMrUo|+DC;I{NUG;CR4WDmPyq)-f>%PL`WE9VWi6wX? zp05LR75KIACQsT=bIW)>P$%almNC_!=QMtvr|&)6HX>Rpp}ka3{k!?JNUFaZ2E$JH zg&UQd3#sjCsTb4NE-Wvfou1iuRAd4qr4;v{a!x2eOj3Kz>gN`TKJ{$J;%slQ@aqCq zu}(nOvr`DZQUK|RnjDUlqQN~B3tWsrwG!y*AN*;ukVV?}9Pc6gu;&r`mMU*t&OAy; zKak7b4FYB@hu?OUbNAhte=9}&p7mriP|<2%(``4nVC{X%&~>{FIRH6P$LF;nt_M>P zX2du9Qz|LgetajBv)KYNfc-$`yQfKZDpw>UJ|>dT{b>w*#IFSHg-R`V1^_NRt%n@Q znd5I@arEj%c4X&z)~(^vsIdiP0&YOiVUUI6=kv?J+kW6D%}Vax9{+l(H>38G_Wil{ z5wwGN!eq^(0b<0{@OPk$sTM2S>ExL$jj=VlR06N1WAo&1BXxZVwh}y>ALj>M1_S z&%Z=nHknS|2i+r2Nlw&A8G9D?y;jtpjPFJhXTwBY2x{K-_Jk)qd4V-dwYkRIG-h2~ z$h|>(-7;UcS@#hxBtPWVY8*$$ZF!a=BfISr@BiX-n+skP#kJorE*?RS?j`bqT+zHm zZFbh6l&q8Y-aMBuLCM#Cf?_fTE^?QG_fNRPg#`~VCCcB6|7v6Q9m67q4PU-2-4UC> zUfYg4M6YvtCNQ}3zp(&t=lQN=kK*^d!ws6vIDW#<7>?`Id z!P^?$E|s~l5h#})?SW2mo#Vme!(D@o21MAqsd~J+yuP_TI$&~X5*BoAl+H0K{Hd+Ole4a~oFEvOcHq_L7I-0X@br$v7 z7~OHnMS5`4x^IQ7YS9<%FlQ>F7t!?108$%HP6f789k#q1LLTj2K$^)-B4~)7kDC3% z-|mj%?z4vB$NRgJ=crf!l6m3gBS1a&Jli~|>854n4d+`we2L!M`!g2|c-ieW6*{lm z{O`Zj$857@Sov*e$xM3xwi?Y)W&_B4`RRa^JqXk~f1{%}Dhxnb6`d1H#w6i*gsd^n-D}@2xd~p4BgkB}zBlQLx#N3u z$P~2X=XjqL3AK9%|6OO=3xkDZ9YGGR4(rNRJcp?$_)QiS^|X$fs%_GD{r=|fFc%xU z?VX5fRXxu6uja8dVQh)|>U~Entc)>o%N)4z5})$llNO39bp6~242i3y-|7rpp z->>(#A*(PawWc!J9*QIaLt3=sCLFv%r^XD^;U}@`%YnTDT&vMrk^;s$ZyK%#D6W3$ z6wv>hk|2Pb&2)ci~~$k_mwaE26yL~PDv^oy<)AG5QsO^!?Pc$?affF8v3^sagTnN4s_e(;0#Siz9> z4-@vytp*yAb08}>x3$%9jy_^;R8qg1kql=5x#|CultOhS8O#>puLJ^f<=p-84JRDF z;6ZN(Q7z4xj7(Ko`v5>-*K)Q>)ex6mCC4IHRDjlJ4{lphYG-3p0vhAD#!g3xswW`t zN|P5X*ajM8wQ3jtiQSHO5lp@q4h&cQLBIz2C$3y$-0>u|p&l0Y(!l?8#5(t8c345g z_N?xt+elq?uv?puc-{VW=*yRQbnygV(`H}2{F3o)VsA=X$HVx&3y|mOpm;0LOhNbx zXifyT!K$pm26~6VK>!*EK}U@4+_kr@?@Y4c@23f2No`^I}7^wjk9RCP50n}a4S1Rq+6R#&07YcWuY$W z;2-~^qLN{~?_&E2%P%p~;%v9lxgfm+N9MrGv2e1ht2iuwt!&#WNs;yvQ9>?@PbS6{ zGWYjg^n8xuPD*&V%3ZB9uRt2g%d743U1!5h8trgakshbHSwI_OiBaCxQ)6yXel0dJ zJe(0bSsXw6V7qT>GNW5*qA`I4FnAE+U`7{MwiC$@h!5?VM@U{K`{S%1pnXy6bP~Uv z(huQM$N9$3GtMB)X?{g0K}0C|=F>eQmi9bu5lJQEx$%S;)FKoP)4HTjA|RgT z*;(E}i{oV+ULMA$M|;7CpS2y24y>4N1#ulKhbs`*)p7^9m{7#x_}annxVBO@cP#A= zpcdUW*vf6n`V<&oWp&!NqXgyh_Aqj~0WA8}#=A|b1S7GY*D)%|fxjN^xNI?}rqnrv zSi-Z#1fYOY-$Uy=l(&qb6L=~r34rY6B>JB9>gp=@m*n8Ie`sh6n?EZ}A?S<#{6SNcFfoE3FcVjlJE?{TL>AUn#@^~7+{=zZK9jA_zYt_gu>IN_ zq%7PELlAe=a59E7Q+1Wr)G&kWhlv6o`&PUpH-Ym~;Dq(t?{y5YS8(>nc~t$W1k!Xx zwvrAOmI@Llz7EY|R;F6agjYb*`dVbNf&t=%9v-*mK4)A=6x7^Eljm;R%EF>F?p8i} zp@<-T!WKjTFfRtmrYw8IQM|I`OcKWn8tJe*Cig8{T$4=!vg+3Rei6jW8A!3 z9R=m54tF`wOiiJWew5r^eh!YBQ>9y4*j9w1<5~MKRI+yJ58okok$Osg9a>kDGEcY! z+QNvx#rd#oMTMlm?i_$Yv1QrpdYX2!1NI%3iH-Gpf{N57r}5_AeeO0tlwV`3B?NLkh0@+nBFsu!WUqCBn|oZ;Ig2AeLj&V3w{BJ| z)qs^@fNibr&+o4cwg_HQ4=$97Sj4kpHJXCrGf7@n##BdG!i?=6ays4UaQHE-%yxn# zl1e_hLQ`JJC9<|vH~-86OtII=&N)o@3MWNSUYtSsV|WFlfK{*_Rz`^b9yq2eFmw^rbijt;)& zb1%LoFYoJ5tfaa8mFBvV>WycWY}!*KfZ}AGrcLqvy->{SN@5Z{#Z);xkLnsHS-Ggq zXGhUbUbV*FpUDE&%PZSRb?GJtMLIi*f>9XZmb+70){U@%$z`E*}o@; z7Cy|Di!<$;!?KjRN*X*-4zM^dEL$!E4|VO4kz9CV6`U3Qu3qS*BC0#TTGMe2P$81D!s1PAwQM(OeX|lK zxB+&n^aedauA-Ez`sDcL_dqYMo+fqsdpMUsNuS%zgvI!=2bh$M%x7H^88hzxFX0x! zl)JcoFpSa&gH1WQ0rF&u&zD{jG`RQe5hzT3U@2bpol{dsDXC|7lstS4$OIrXz2SXt z%|)DTwUWo~Ix4=}f!~al1&gKeNC4((ARr--cHaleTde5PiltW$1no2PU&w6LF94HpR7!GE?d*V3^eUEqVKyG^9A zn%gg6{~1a$>lqD?SAKwc3v6i#(Lz`lHn(GFbE8!_+d?6@b`uAqDPB&>w#r`RAL%oM ze81hO7wcSX{=V$VHJ*dJ04HXB3un_Uoto0I>4w@0%=J=nEVoBd7TU`ILT?ctU$K8>xB+xf z8N&hU@LaYlpaAt6sP#K=_Pn~FLJCkCFO&M2Kg0Nd>&zB*vnXn}{%bl4I%Q74$haR6 zAl%uBTLQHuBuz12-kWX;!r;(+ry?auoGA^(16Hz=tDL8YN59oH<^_xBeO>Tso~FDr zc}ot=N!wg8c{VulV1~mI=p)NAhnfdG%x{Io!H(Q_&x6iKvvzlXx1GZ#I>KJ!0l1x} zw*p>OTloDLy}TozT0K3=#)j4p&}3ev^Wi;Ace#SPg?do zH0;$}{Y?T=8+1qypC{{an9+3dH5*EE+GhPjClAHDYD z(;Q+I(eafB#uy)N`jzGkS||GZH?HgNQ<<(NvZWh$lW9`Eq2d#0H#ge?n(Sg))oj!- zv%H*19Ml`v;JPTx(1TghJiMPWWF5(ZzqaNx=1uBnatZ+GYDs^xx?l+{jrZaj57%%5 z>yT*&yu(y2xe)+?5H@7+rk1qB!JVkke&p{97CjHq?FF$NEXjk(WPx9h5Lm*6;vbq) zIyxY}#+YWyoQO7p8f^w1`P9WDjoH`e=Rup3!*f*&SEn*h+@MPFFaFZ;vALW)EZhi> zq+I)0QzKh$Ioas4G6y~{=C$dIoT=q1#MyquOaITV&*6a5E*zfKY=61p+J93rstss% z?s96MKY+OmlY3mBzDHb)-Lx`!y@fWeqaF_3L z)W?YRgE?DLQWgp$u&#E{9wJn=5NAvvrj(`s|CG|fC(zv5(NxUH5CAJ`cZJy+Y0?*A z#tohH7=oOSGhQ@~s#?OrzRZ#yd7mg-$1MS1swSg&G zC1PndAfwgdf1Nb^c;`I_;3`nxSoHOUy-|s2yS$Z}b&cPt?Pf>mLX~9X$N0wc-Q2#hh%<#xR55nhdDnoxT)5q<{YVt43JcGA7Vd z;wT|8TRF}R6|y3By*ed%L=20!9@{@d>N1^}`bba1eqBDZn4DZuS(D?u(rHAEy#i$1 zjDy;4k3c~r9Gt)fe6txP?o8g-V97+umP`b zv9`HdsSyBM=;$D^Py?X&R{&s!47m3bXY{@kyfO!f2HC3iVlrWM)ty!R8==~ESvQj< z=76$K(2r@iL~JG@xRJj)F|jhzEfG-ICOJ>`>bPUC{z1y5UZCXTJslaj9a|hsCR1RcW5PvX>T>*4b2^33rUc9&gG%a=L)8(f3 zwKxF%6k%X(<(HeTIjy?JwB90pmuvD`efdw<&&X%|tCTdR-~CHnA8KSO>0&cIh-Wp+ z?d>wbrgb^;Xx-1Wx(6)IJiQIF$6l4);2pHqP*)7A7W_gcVO7@(M2 zD4hny47YhkiZQ-nD;oh#F zV}TvaYDq7Q0BTQ!I3IdfZ8+F5PTV7fx&mmGXeI<>m07U=Y^~>MsIS;eqgU~d^qln~G=x1K2yzo;wK%>?77md7x_!fsNpdv3H zA|BZYpPk_(y=dQ#Z^&uToLe5Dk6Sp=fHyY2UVQE(5=g0{QeR4NHy$5>+C`zFc#$a- z_p!Df{ncbD88{TUf$rYAfS)xt!~?Khjox?`sgq8#^SX~8)aWHS&G|G2q}+HZi9-s+ z>9r&A5Q`aP!kf$dW-2OQs?m{9zT_JYmo3^@nJ<1-)Q5B{&A3*p5us{=FKd#8`+Xu+ zE5DJ1UwXl@Le(;<_B$KUQKMDPb0s|9FgewY)5)Rz9&}_#ri#np2PU zt!xoOk2>i!H8ut5SNWI6*!iJkJY)q_$JoesZ_)fByD0h&wOkm%aanP8jHw)T^{uvv zTutR3j*QQRCHpny%@fVhy$#yUcMWg1hd=&~_H;4=%IeuJxoHLTMh_yra$qlP6a!XQ zRLC~Ot6D0)1oDHM!!euenuC?muq&uVWs_C za`*Q%@&!WMkrXu|7H#+hy^A+Ndt2i|@@igvij{)*aZ9MM*w2;3I#BE!!aqxO!JR$D zakrqy6YSVwRLQZsh^be->XZHk-T|&YZBaI#Aa$@;!r~H&Tbp4^ z?eRNApD|TK#gi0atSBX(KERZxxwh^}O}J{i?!VvQVY!mC6fa-MIRguiI&=5cQ>4+h z^Lo9hXlc4l_Vqxud z97l3o46JM+i8n64UQ~bH^~a$H2G?qdYHXKj=P+6E^H`4GL|hQDHP^-R0qfmD@QDfC z<(XMXw#KSzxw$*^=ht0HH{PE_You@MPe>zUPtaezbRcmxUoY;NN5S-#FdzATsLy!2 zNGu`v;0Sut(%Lv9locK4y`Z2Ubo9(lltOS+pLVk(Xo^i&yvOL)`O8R#A>P?0?#XEi zTDBNidW#ljWc-`&`I3z^HOwvcf40gkdYlfXK5r8UguZNZIs|3$pR6FzpLD}%k6I`o z@=IUMqhS;H8IQi}c&V5qTT}P+94#(oS+;gk<=R7J8Xg@n%Qu=3a4|B5RaBl_$vlbk z?IzfxsB0q?WIN~C*|JUhXgl*Y*PZNAgA@PC9@63%X+gKKDo%}I0hY-2KdD@2aA}nxht5EZEO-O3uA8} zh1dzDP^^?|*YmJBo@15QXsRFH5$l@G@A-ArM48kw)_ckGL1WVc`*1udttV8RxN_b; zKXKyj8HFk83x`&0u!hO|Qjp@Gp^RFaxR5%-$qN`A<>97&OOAAHn0S6h0PkhZsjbv{ zJRKIaY%J%tuIij5zxsW% zSN9`UE2C|bHXVI;MSU<18p6nNJWid0_H7Y5{jDYO@#w=OrYcyP1-pPlm`}0P9OyJf zMf-P*up#h-_IS(>8o31GstY^KnKc^l$Uv-$4X4SNI1Xz{_NN`|4w$^ z-fP(>hokt_h;m89D+2S#yp8wfEgYuD`pYj1*w#~H7+a?u;fqJZ(w@i z8whG&+kuWZf2>&bFK`l=zn{jK9|pI?k#xPKLEhCDPm&!f;q)Tci|QCK1cM_e&|3Yf zJ(V|FYPMQFn2m#ALUJ-b?x5}fq={TatX8C>^bc1ULm|L2eg5yI$gJX&OrY;EY#hq- zvcj?7{%JV`t!@_-2FJuZ3^-Vy

    0r-7XWEIUpupd9gVduW=A%KX?WuZv2hAXF9A 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 stetkit.com - A little ink. - A lot of personality. + Explain the UI. + Keep it interactive. - Live UI annotations. Agent and developer first. - Project skills. Typed API. Zero runtime dependencies. - v0.1.0 + Hand-drawn annotations for docs, demos, and reviews. + Write code. Use your agent. Capture with Playwright. + Open source · MIT - a little less - perfect. - a little more you. + Point to + what changed. + Try the controls. diff --git a/website/scripts/capture-examples.mjs b/website/scripts/capture-examples.mjs new file mode 100644 index 0000000..7ac3e36 --- /dev/null +++ b/website/scripts/capture-examples.mjs @@ -0,0 +1,30 @@ +import { chromium, expect } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; + +const examples = [ + 'workspace-deletion', + 'security-handoff', + 'form-review', + 'feature-showcase', + 'guided-tutorial', + 'live-documentation', +]; +const browser = await chromium.launch(); +try { + const page = await browser.newPage({ + viewport: { width: 900, height: 1200 }, + reducedMotion: 'reduce', + }); + for (const example of examples) { + await page.goto(`${process.env.STET_SITE_URL ?? 'http://127.0.0.1:4173'}/use-cases/${example}`); + const fixture = page.locator('.scenario-view > .live-stage .application'); + await fixture.scrollIntoViewIfNeeded(); + await page.evaluate(() => document.fonts.ready); + await expect(page.locator('.stet-overlay:not([hidden])').first()).toBeVisible(); + await fixture.screenshot({ + path: fileURLToPath(new URL(`../public/images/example-${example}.png`, import.meta.url)), + }); + } +} finally { + await browser.close(); +} diff --git a/website/scripts/capture-handoff.mjs b/website/scripts/capture-handoff.mjs new file mode 100644 index 0000000..814894a --- /dev/null +++ b/website/scripts/capture-handoff.mjs @@ -0,0 +1,58 @@ +import { chromium, firefox, expect } from '@playwright/test'; +import { createStet } from '@funsaized/stet/playwright'; +import { mkdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +const browserType = process.env.STET_CAPTURE_BROWSER === 'firefox' ? firefox : chromium; +const browser = await browserType.launch(); +try { + const page = await browser.newPage({ + viewport: { width: 1200, height: 1100 }, + reducedMotion: 'reduce', + }); + await page.goto( + `${process.env.STET_SITE_URL ?? 'http://127.0.0.1:4173'}/use-cases/security-handoff`, + ); + await page.getByRole('button', { name: 'Annotations on', exact: true }).click(); + await expect(page.locator('.stet-overlay')).toHaveCount(0); + // Behavior is checked separately from the annotation and the screenshot. + const update = page.getByRole('button', { name: 'Update demo password' }); + await expect(update).toBeDisabled(); + await page.getByLabel('New password').fill('demo-password-only'); + await expect(update).toBeEnabled(); + await page.getByLabel('New password').fill(''); + await page.getByRole('heading', { name: 'A little more peace of mind.' }).click(); + const fixture = page.locator('[data-fixture="security"]'); + await fixture.scrollIntoViewIfNeeded(); + await page.evaluate(() => document.fonts.ready); + const stet = await createStet(page); + try { + const target = page.getByRole('button', { name: 'Revoke session', exact: true }); + const note = await stet.circle(target, { + seed: 12, + visible: false, + animate: true, + description: 'Review session revocation.', + }); + await note.show(); + await note.refresh(); + await expect(page.locator('.stet-overlay--circle:not([hidden])')).toHaveCount(1); + expect(await page.evaluate(() => document.getAnimations().length)).toBe(0); + if (browserType === chromium) { + const directory = new URL('../public/images/', import.meta.url); + await mkdir(directory, { recursive: true }); + // Locator screenshots include body-level overlays intersecting this rectangle. + await fixture.screenshot({ + path: fileURLToPath(new URL('playwright-handoff.png', directory)), + }); + } + } finally { + await stet.dispose(); + } + await expect(page.locator('.stet-overlay')).toHaveCount(0); + await page.getByRole('button', { name: 'Revoke session', exact: true }).click(); + await expect(page.getByText('Demo session revoked.', { exact: true })).toBeVisible(); + console.log(`${browserType.name()}: injected, captured, disposed; native controls verified.`); +} finally { + await browser.close(); +} diff --git a/website/scripts/check-framework-guides.mjs b/website/scripts/check-framework-guides.mjs new file mode 100644 index 0000000..2bf56ba --- /dev/null +++ b/website/scripts/check-framework-guides.mjs @@ -0,0 +1,143 @@ +// Runnable check for the five framework guide first-mark examples. +// +// The first examples live as strings in src/frameworks.ts, so this check imports +// that module directly. Node's type stripping loads the .ts import; Node 22.12, +// the website's minimum, needs --experimental-strip-types, so re-exec when the +// running Node cannot strip types yet. +// +// The check then asserts the documented shape of each first example (stylesheet, +// visibility, typed handle callback, reveal, and replay) and compiles the React, +// Angular, Vue, and Svelte sources with the repo's installed compilers under +// strict settings. Vanilla stays plain JavaScript, so only its return-the-handle +// contract is asserted; the optional TypeScript helper the page shows is compiled +// alongside after its signature check. +// +// Run from the website package: node scripts/check-framework-guides.mjs + +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const self = fileURLToPath(import.meta.url); + +// Node 22.12 cannot import a .ts file without opting in. Later releases strip +// types by default, so this only re-execs old runtimes. +if (!process.features.typescript && !process.execArgv.includes('--experimental-strip-types')) { + const result = spawnSync( + process.execPath, + ['--experimental-strip-types', ...process.execArgv, self, ...process.argv.slice(2)], + { stdio: 'inherit' }, + ); + process.exit(result.status ?? 1); +} + +const { frameworks } = await import('../src/frameworks.ts'); + +const website = resolve(here, '..'); +const repo = resolve(website, '..'); +const stet = join(website, 'node_modules', '@funsaized', 'stet'); +const NEUTRAL = 'Review this action before continuing.'; +const COMPILED = ['react', 'angular', 'vue', 'svelte']; + +function guide(id) { + const found = frameworks.find((framework) => framework.id === id); + assert.ok(found, `missing ${id} framework guide`); + return found; +} + +function checkFirst(id) { + const { first, filename } = guide(id); + const label = `${id}/${filename}`; + + assert.ok(first.includes('@funsaized/stet/style.css'), `${label}: missing stylesheet import`); + assert.ok(first.includes('animate'), `${label}: circle reveal is not enabled`); + assert.ok(first.includes('visible'), `${label}: missing visibility option`); + assert.ok(first.includes(NEUTRAL), `${label}: description is not the neutral text`); + assert.ok(!first.includes('Add a title to enable Save.'), `${label}: stale save description`); + + if (id === 'react') { + assert.ok(first.includes('visible={visible}'), `${label}: visibility is not declarative`); + assert.ok(first.includes('onHandle={onHandle}'), `${label}: handle callback is not bound`); + } else if (id === 'angular') { + assert.ok(first.includes('stetOnHandle'), `${label}: missing stetOnHandle input`); + } else if (id === 'vue' || id === 'svelte') { + assert.ok(first.includes('onHandle'), `${label}: missing handle callback`); + } + + if (id === 'vanilla') { + assert.ok(/return circle\(button/.test(first), `${label}: does not return the handle`); + assert.ok(first.includes('handle.destroy()'), `${label}: missing destroy guidance`); + assert.ok( + !first.includes('target: Element'), + `${label}: default example is not plain JavaScript`, + ); + return first; + } + + assert.ok(first.includes('StetHandle | null'), `${label}: handle callback is not typed`); + assert.ok(first.includes('replay()'), `${label}: missing replay control`); + return first; +} + +const cwd = mkdtempSync(join(repo, '.framework-guide-check-')); +try { + for (const id of COMPILED) { + const { first, filename } = guide(id); + checkFirst(id); + mkdirSync(join(cwd, id), { recursive: true }); + writeFileSync(join(cwd, id, filename), first); + } + + checkFirst('vanilla'); + mkdirSync(join(cwd, 'vanilla'), { recursive: true }); + const typescript = readFileSync(join(stet, 'agent', 'templates', 'vanilla', 'circle.ts'), 'utf8'); + assert.ok( + typescript.includes('export function annotate(target: Element)'), + 'vanilla/circle.ts: optional TypeScript helper signature changed', + ); + writeFileSync(join(cwd, 'vanilla', 'typescript.ts'), typescript); + + writeFileSync( + join(cwd, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'Bundler', + strict: true, + skipLibCheck: true, + jsx: 'react-jsx', + experimentalDecorators: true, + useDefineForClassFields: false, + noEmit: true, + lib: ['ES2022', 'DOM'], + types: ['svelte'], + paths: { + '@funsaized/stet': [join(stet, 'dist', 'index.d.ts')], + '@funsaized/stet/*': [join(stet, 'dist', '*')], + }, + }, + include: ['vanilla/*.ts', 'react/*.tsx', 'angular/*.ts', 'vue/*.vue', 'svelte/*.svelte'], + angularCompilerOptions: { strictTemplates: true, compilationMode: 'partial' }, + }), + ); + + const bin = (name) => resolve(repo, 'node_modules', '.bin', name); + for (const [command, args] of [ + ['tsc', ['-p', join(cwd, 'tsconfig.json')]], + ['ngc', ['-p', join(cwd, 'tsconfig.json')]], + ['vue-tsc', ['-p', join(cwd, 'tsconfig.json'), '--noEmit']], + ['svelte-check', ['--workspace', cwd, '--tsconfig', './tsconfig.json']], + ]) { + execFileSync(bin(command), args, { stdio: 'inherit' }); + } + + console.log( + `All ${COMPILED.length} framework guide first examples keep their shape and compile; vanilla returns its handle.`, + ); +} finally { + rmSync(cwd, { recursive: true, force: true }); +} diff --git a/website/scripts/check-playground-snippets.mjs b/website/scripts/check-playground-snippets.mjs new file mode 100644 index 0000000..d591572 --- /dev/null +++ b/website/scripts/check-playground-snippets.mjs @@ -0,0 +1,198 @@ +// Runnable check for all 35 playground snippets (7 primitives x 5 frameworks). +// +// Reads the canonical templates from the installed @funsaized/stet package, +// renders each snippet with the same pure helper the playground uses, asserts +// the selected options survive serialization, then compiles the generated +// vanilla/React/Angular/Vue/Svelte sources with the repo's installed toolchain. +// +// Run from the website package: node scripts/check-playground-snippets.mjs + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { renderPlaygroundSnippet } from '../src/components/playground-snippet.ts'; + +const here = dirname(fileURLToPath(import.meta.url)); +const website = resolve(here, '..'); +const repo = resolve(website, '..'); +const stet = join(website, 'node_modules', '@funsaized', 'stet'); +const capabilities = JSON.parse(readFileSync(join(stet, 'agent', 'capabilities.json'), 'utf8')); +const templates = join(stet, 'agent', 'templates'); +const REVEAL = new Set(['circle', 'box', 'underline']); +const MARK_KIND = 'right'; + +// One value carrying every construct the old helper mishandled: apostrophe, +// double quote, angle brackets, ampersand, backtick, interpolation, braces, +// script-closing text, and a backslash. +const NASTY = 'Apostrophe\'s "quotes" & `tick` ${brace} {braces} back\\slash'; + +function optionsFor(primitive) { + const shared = { + seed: 987654321, + roughness: 1.7, + stroke: '#416ba0', + width: 3.1, + padding: 9, + description: NASTY, + }; + switch (primitive) { + case 'circle': + case 'box': + case 'underline': + return { ...shared, boil: 0.8, resketchOnHover: true, animate: true }; + case 'highlight': + return { + seed: shared.seed, + roughness: shared.roughness, + fill: '#f6e89d', + description: NASTY, + }; + case 'sticky': + return { + seed: shared.seed, + roughness: shared.roughness, + stroke: '#34372f', + fill: '#f5d5c9', + padding: shared.padding, + text: NASTY, + side: 'left', + offsetX: -12, + offsetY: 7, + description: NASTY, + }; + case 'arrow': + return { + seed: shared.seed, + roughness: shared.roughness, + stroke: shared.stroke, + width: shared.width, + curvature: -0.24, + label: NASTY, + labelOffsetX: 11, + labelOffsetY: -9, + description: NASTY, + }; + case 'mark': + return { ...shared }; + default: + throw new Error(`Unknown primitive ${primitive}`); + } +} + +function encodeValue(value) { + return JSON.stringify(value) + .replace(/&/g, '\\u0026') + .replace(/'/g, '\\u0027') + .replace(//g, '\\u003e'); +} + +function selectedFor(framework, primitive, options) { + return primitive === 'mark' && framework !== 'vanilla' + ? { ...options, kind: MARK_KIND } + : options; +} + +function embeddedOptions(framework, primitive, code) { + let match; + if (framework === 'vanilla') { + match = code.match(/const handle = \w+\(target, (?:[^,{]+, )?(\{[\s\S]*?\n \})\);/); + } else if (framework === 'angular') { + match = code.match(/readonly options: [^=]+ = (\{[\s\S]*?\n \});/); + } else { + match = code.match(/\{ \.\.\.(\{[\s\S]*?\n \}), (?:to: destination, )?onHandle \}/); + } + assert.ok(match, `${framework}/${primitive}: could not extract embedded options`); + return JSON.parse(match[1]); +} + +function checkSnippet(framework, primitive, code, options) { + const id = `${framework}/${primitive}`; + const selected = selectedFor(framework, primitive, options); + + assert.ok(code.includes('style.css'), `${id}: missing stylesheet import`); + assert.ok(!code.includes('"seed":42'), `${id}: stale canonical seed`); + assert.ok( + !code.includes('Review this action before continuing.'), + `${id}: stale canonical description`, + ); + if (framework === 'vanilla') assert.ok(code.includes('destroy'), `${id}: missing cleanup`); + else assert.ok(code.includes('onHandle'), `${id}: missing handle callback`); + + if (REVEAL.has(primitive)) { + assert.ok(/\banimate\b/.test(code), `${id}: requested reveal missing`); + } else { + assert.ok(!/\banimate\b/.test(code), `${id}: unsupported reveal leaked from template`); + } + + if (primitive === 'arrow') { + assert.ok(code.includes('destination'), `${id}: arrow missing destination`); + } + + if (framework === 'react') { + const props = Object.entries(selected) + .map(([key, value]) => `${key}={${encodeValue(value)}}`) + .join('\n '); + assert.ok(code.includes(props), `${id}: React props not serialized verbatim`); + } else { + assert.deepEqual(embeddedOptions(framework, primitive, code), selected, `${id}: option drift`); + } +} + +const cwd = mkdtempSync(join(repo, '.playground-snippet-check-')); +try { + let count = 0; + for (const framework of Object.keys(capabilities.frameworks)) { + const ext = capabilities.frameworks[framework].extension; + mkdirSync(join(cwd, framework)); + for (const primitive of Object.keys(capabilities.primitives)) { + const canonical = readFileSync(join(templates, framework, `${primitive}.${ext}`), 'utf8'); + const options = optionsFor(primitive); + const code = renderPlaygroundSnippet(framework, primitive, options, MARK_KIND, canonical); + checkSnippet(framework, primitive, code, options); + writeFileSync(join(cwd, framework, `${primitive}.${ext}`), code); + count += 1; + } + } + + writeFileSync( + join(cwd, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'Bundler', + strict: true, + skipLibCheck: true, + jsx: 'react-jsx', + experimentalDecorators: true, + useDefineForClassFields: false, + noEmit: true, + lib: ['ES2022', 'DOM'], + types: ['svelte'], + paths: { + '@funsaized/stet': [join(stet, 'dist', 'index.d.ts')], + '@funsaized/stet/*': [join(stet, 'dist', '*')], + }, + }, + include: ['vanilla/*.ts', 'react/*.tsx', 'angular/*.ts', 'vue/*.vue', 'svelte/*.svelte'], + angularCompilerOptions: { strictTemplates: true, compilationMode: 'partial' }, + }), + ); + + const bin = (name) => resolve(repo, 'node_modules', '.bin', name); + for (const [command, args] of [ + ['tsc', ['-p', join(cwd, 'tsconfig.json')]], + ['ngc', ['-p', join(cwd, 'tsconfig.json')]], + ['vue-tsc', ['-p', join(cwd, 'tsconfig.json'), '--noEmit']], + ['svelte-check', ['--workspace', cwd, '--tsconfig', './tsconfig.json']], + ]) { + execFileSync(bin(command), args, { stdio: 'inherit' }); + } + + console.log(`All ${count} playground snippets serialize their options and compile.`); +} finally { + rmSync(cwd, { recursive: true, force: true }); +} diff --git a/website/scripts/prerender.mjs b/website/scripts/prerender.mjs index a329223..538ad5f 100644 --- a/website/scripts/prerender.mjs +++ b/website/scripts/prerender.mjs @@ -8,6 +8,8 @@ import { structuredData, } from '../.ssr/entry-server.js'; const template = await readFile(new URL('../dist/index.html', import.meta.url), 'utf8'); +const sitePackage = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); +const preview = sitePackage.dependencies['@funsaized/stet'].startsWith('file:'); const manifest = JSON.parse( await readFile(new URL('../dist/.vite/manifest.json', import.meta.url), 'utf8'), ); @@ -29,7 +31,7 @@ for (const p of [ const tags = [ `${escape(p.title)}`, ``, - ``, + ``, ``, ``, ``, @@ -81,12 +83,14 @@ for (const p of [ await writeFile( new URL('../dist/sitemap.xml', import.meta.url), `\n\n${publicPages - .filter((p) => p.index && p.sitemap) + .filter((p) => !preview && p.index && p.sitemap) .map((p) => ` ${canonical(p.path)}`) .join('\n')}\n\n`, ); await writeFile( new URL('../dist/robots.txt', import.meta.url), - `User-agent: *\nAllow: /\nSitemap: ${ORIGIN}/sitemap.xml\n`, + preview + ? 'User-agent: *\nDisallow: /\n' + : `User-agent: *\nAllow: /\nSitemap: ${ORIGIN}/sitemap.xml\n`, ); console.log(`Prerendered ${publicPages.length} public pages and a noindex 404.`); diff --git a/website/scripts/test-seo.mjs b/website/scripts/test-seo.mjs index a47d789..97d7123 100644 --- a/website/scripts/test-seo.mjs +++ b/website/scripts/test-seo.mjs @@ -3,6 +3,8 @@ import { readFile } from 'node:fs/promises'; import { Window } from 'happy-dom'; import { publicPages, canonical, ORIGIN } from '../.ssr/entry-server.js'; const sitemap = await readFile('dist/sitemap.xml', 'utf8'); +const sitePackage = JSON.parse(await readFile('package.json', 'utf8')); +const preview = sitePackage.dependencies['@funsaized/stet'].startsWith('file:'); const social = await readFile('dist/social.png'); assert.equal(social.readUInt32BE(16), 1200); assert.equal(social.readUInt32BE(20), 630); @@ -10,7 +12,7 @@ const urls = [...sitemap.matchAll(/(.*?)<\/loc>/g)].map((m) => m[1]); assert.deepEqual( urls.sort(), publicPages - .filter((p) => p.index && p.sitemap) + .filter((p) => !preview && p.index && p.sitemap) .map((p) => canonical(p.path)) .sort(), ); @@ -32,7 +34,7 @@ for (const p of publicPages) { assert(!descriptions.has(p.description)); descriptions.add(p.description); assert.equal(doc.querySelector('link[rel=canonical]')?.getAttribute('href'), canonical(p.path)); - assert(!meta('robots').includes('noindex')); + assert.equal(meta('robots'), preview ? 'noindex,follow' : 'index,follow'); assert.equal(doc.querySelectorAll('h1').length, 1, p.path); const heading = doc.querySelector('h1').textContent; assert(!headings.has(heading), `Duplicate H1: ${p.path}`); @@ -58,7 +60,7 @@ for (const p of publicPages) { assert.equal(schema['@type'], 'BreadcrumbList'); schema.itemListElement.forEach((item, i) => { assert.equal(item.position, i + 1); - assert(urls.includes(item.item)); + assert(publicPages.some((page) => canonical(page.path) === item.item)); assert(item.name); }); assert.equal(schema.itemListElement.at(-1).item, canonical(p.path)); @@ -88,7 +90,11 @@ assert.deepEqual( 'Every public page must be reachable from the homepage', ); assert((await readFile('dist/404.html', 'utf8')).includes('noindex,follow')); -assert((await readFile('dist/robots.txt', 'utf8')).includes(`${ORIGIN}/sitemap.xml`)); +assert( + (await readFile('dist/robots.txt', 'utf8')).includes( + preview ? 'Disallow: /' : `${ORIGIN}/sitemap.xml`, + ), +); const config = JSON.parse(await readFile('vercel.json', 'utf8')); assert(!config.rewrites); assert.equal(config.cleanUrls, true); diff --git a/website/src/components/FrameworkCode.tsx b/website/src/components/FrameworkCode.tsx index 8154d6a..09a6db4 100644 --- a/website/src/components/FrameworkCode.tsx +++ b/website/src/components/FrameworkCode.tsx @@ -1,54 +1,56 @@ -import { trackAction } from '../analytics'; import { useState } from 'react'; +import { trackAction } from '../analytics'; +import { frameworks } from '../frameworks'; import { Code } from './Code'; import { CopyButton } from './CopyButton'; import { Icon } from './Icon'; -import { canonicalCode } from '../showcase/canonical'; -const snippets = { - React: canonicalCode('react'), - JavaScript: canonicalCode('vanilla'), - Vue: canonicalCode('vue'), - Svelte: canonicalCode('svelte'), - Angular: canonicalCode('angular'), -}; -type Framework = keyof typeof snippets; + +const tabs = ['react', 'vanilla', 'vue', 'svelte', 'angular'].map((id) => + frameworks.find((framework) => framework.id === id)!, +); + +export function Snippet({ label, text }: { label: string; text: string }) { + return ( +
    +
    + {label} + +
    + +
    + ); +} + export function FrameworkCode() { - const [framework, setFramework] = useState('React'); + const [id, setId] = useState<(typeof tabs)[number]['id']>('react'); + const selected = tabs.find((framework) => framework.id === id)!; return (
    - {(Object.keys(snippets) as Framework[]).map((f) => ( + {tabs.map((framework) => ( ))}
    - { - { - React: 'AnnotatedAction.tsx', - JavaScript: 'annotate.ts', - Vue: 'AnnotatedAction.vue', - Svelte: 'AnnotatedAction.svelte', - Angular: 'annotated-action.component.ts', - }[framework] - } + {selected.filename} - +
    - +
    - That’s it. Your button is still your button. + Import the stylesheet. Destroy handles you create.
    diff --git a/website/src/components/HeroDemo.tsx b/website/src/components/HeroDemo.tsx index 0219d88..03640c0 100644 --- a/website/src/components/HeroDemo.tsx +++ b/website/src/components/HeroDemo.tsx @@ -1,5 +1,6 @@ -import { useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { Circle, Underline } from '@funsaized/stet/react'; +import type { StetHandle } from '@funsaized/stet'; import { Icon } from './Icon'; export function HeroDemo() { @@ -10,6 +11,10 @@ export function HeroDemo() { const [marks, setMarks] = useState(true); const [motion, setMotion] = useState(false); const [seed, setSeed] = useState(12); + const handle = useRef(null); + const onHandle = useCallback((next: StetHandle | null) => { + handle.current = next; + }, []); const ready = title.trim().length > 0; const boil = motion ? 0.3 : 0; const reason = ready @@ -58,7 +63,7 @@ export function HeroDemo() { {saved ? 'Saved' : ready ? 'Ready' : 'Blocked'}

    {ready ? 'Save is ready.' : 'Why is Save disabled?'}

    -

    A screenshot cannot show this. Change the title and the reason follows.

    +

    Type a title. The explanation follows the form’s state.

    @@ -100,6 +105,14 @@ export function HeroDemo() { Real controls. Go on, use them.
    +
    - {marks && ( + { <> - )} + } ); } diff --git a/website/src/components/Layout.tsx b/website/src/components/Layout.tsx index d3b30f6..f00e9e0 100644 --- a/website/src/components/Layout.tsx +++ b/website/src/components/Layout.tsx @@ -1,12 +1,29 @@ -import { Link, Outlet, useRouterState } from '@tanstack/react-router'; +import { Link, Outlet, useRouter, useRouterState } from '@tanstack/react-router'; +import { useEffect, useRef, useState } from 'react'; import { Icon } from './Icon'; -import { REPO } from '../constants'; +import { REPO, VERSION, IS_PREVIEW } from '../constants'; +import '../product.css'; import { usePageMeta } from '../usePageMeta'; import { findPage } from '../seo'; export function Layout() { usePageMeta(); const path = useRouterState({ select: (s) => s.location.pathname }); const page = findPage(path); + const router = useRouter(); + const [menuOpen, setMenuOpen] = useState(false); + const menuButton = useRef(null); + useEffect(() => router.subscribe('onBeforeNavigate', () => setMenuOpen(false)), [router]); + useEffect(() => { + if (!menuOpen) return; + const close = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setMenuOpen(false); + menuButton.current?.focus(); + } + }; + document.addEventListener('keydown', close); + return () => document.removeEventListener('keydown', close); + }, [menuOpen]); return ( <> @@ -17,9 +34,22 @@ export function Layout() { stet••• -